feat(slint): Last.fm and ListenBrainz scrobblers wired to player and settings

Re-implements the parked scrobblers-wip stash intent on the current tree
(the stash no longer applied; it remains untouched in the stash list) with
three corrections: the Last.fm offline queue uses the SHARED per-user
offline_settings.db scrobble_queue (byte-identical SQL to Tauri - queues
flush from either frontend) instead of a private table; the ListenBrainz
cache opens Tauri's per-user path so LB creds and listen_queue are truly
shared (a Tauri LB sign-in pre-seeds Slint); and the queue flush is driven
by the offline engine's online edge (plus initial flush), not login-only.

New frontend-agnostic qbz_app::settings::scrobblers store (master enable,
Last.fm session/username, LB token/username; tests) and the shared
scrobble-queue API on OfflineModeStore. Player wiring hooks the de-duped
track-change edge inside the QConnect remote gate: now-playing fires on
change (skipped offline), the scrobble arms at min(50%, 240s) with a
generation counter cancelling stale timers, all sources scrobble (Tauri
parity incl. the #360 version-enriched title). Offline or call failure
queues; Last.fm flush honors the 50-batch and 14-day cutoff.

Settings gains an Integrations section (Plex-section idiom): Last.fm
browser-auth connect/finish/disconnect, ListenBrainz token field +
validate, per-service enable toggles. Known gap documented: Last.fm creds
cannot be shared with Tauri (webview localStorage); LB creds and both
queues are shared.

cc
This commit is contained in:
vicrodh committed 2026-06-10 02:52:17 -06:00
1 parent 81587f4d17
commit 952846b6a0
12 files changed
+1945 -2

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ pub mod connectivity;
pub mod store;
pub use connectivity::{Connectivity, ConnectivityActor, ConnectivitySnapshot};
pub use store::{OfflineModeSettings, OfflineModeStore};
pub use store::{OfflineModeSettings, OfflineModeStore, QueuedScrobble};
use serde::{Deserialize, Serialize};
use std::path::Path;
+165
View File
@@ -28,6 +28,21 @@ pub struct OfflineModeSettings {
pub show_network_folders_in_manual_offline: bool,
}
/// One row of the Last.fm offline scrobble queue (`scrobble_queue`). Mirrors
/// Tauri's `offline::QueuedScrobble` — same table, same per-user file, so
/// scrobbles queued by one frontend flush from the other.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QueuedScrobble {
pub id: i64,
pub artist: String,
pub track: String,
pub album: Option<String>,
pub timestamp: i64,
pub created_at: i64,
pub sent: bool,
}
pub struct OfflineModeStore {
conn: Connection,
}
@@ -162,6 +177,121 @@ impl OfflineModeStore {
.map_err(|e| format!("Failed to set pre_offline_stream_first_track: {}", e))?;
Ok(())
}
// === Last.fm scrobble queue ===
//
// Minimal API over the `scrobble_queue` table the base schema already
// creates. SQL kept IDENTICAL to Tauri's `src-tauri/src/offline/mod.rs`
// scrobble-queue methods so both frontends interoperate on the same rows.
/// Queue a scrobble for later submission to Last.fm.
pub fn queue_scrobble(
&self,
artist: &str,
track: &str,
album: Option<&str>,
timestamp: i64,
) -> Result<i64, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
self.conn
.execute(
"INSERT INTO scrobble_queue (artist, track, album, timestamp, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![artist, track, album, timestamp, now],
)
.map_err(|e| format!("Failed to queue scrobble: {}", e))?;
Ok(self.conn.last_insert_rowid())
}
/// Unsent scrobbles, oldest first (cap at 50 — the Last.fm batch limit).
pub fn get_queued_scrobbles(&self, limit: u32) -> Result<Vec<QueuedScrobble>, String> {
let mut stmt = self
.conn
.prepare(
"SELECT id, artist, track, album, timestamp, created_at, sent
FROM scrobble_queue WHERE sent = 0 ORDER BY timestamp ASC LIMIT ?1",
)
.map_err(|e| format!("Failed to prepare statement: {}", e))?;
let scrobbles = stmt
.query_map(params![limit], |row| {
Ok(QueuedScrobble {
id: row.get(0)?,
artist: row.get(1)?,
track: row.get(2)?,
album: row.get(3)?,
timestamp: row.get(4)?,
created_at: row.get(5)?,
sent: row.get::<_, i64>(6)? != 0,
})
})
.map_err(|e| format!("Failed to query queued scrobbles: {}", e))?;
scrobbles
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to collect queued scrobbles: {}", e))
}
/// Batch mark scrobbles as sent.
pub fn mark_scrobbles_sent(&self, ids: &[i64]) -> Result<(), String> {
if ids.is_empty() {
return Ok(());
}
let placeholders: Vec<String> = ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"UPDATE scrobble_queue SET sent = 1 WHERE id IN ({})",
placeholders.join(",")
);
let mut stmt = self
.conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare statement: {}", e))?;
let sql_params: Vec<&dyn rusqlite::ToSql> =
ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect();
stmt.execute(sql_params.as_slice())
.map_err(|e| format!("Failed to mark scrobbles as sent: {}", e))?;
Ok(())
}
/// Delete sent scrobbles older than `older_than_days` (post-flush cleanup).
pub fn cleanup_sent_scrobbles(&self, older_than_days: u32) -> Result<u32, String> {
let cutoff = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
- (older_than_days as i64 * 24 * 60 * 60);
let deleted = self
.conn
.execute(
"DELETE FROM scrobble_queue WHERE sent = 1 AND created_at < ?1",
params![cutoff],
)
.map_err(|e| format!("Failed to cleanup sent scrobbles: {}", e))?;
Ok(deleted as u32)
}
/// Count of queued (unsent) scrobbles.
pub fn queued_scrobble_count(&self) -> Result<u32, String> {
self.conn
.query_row(
"SELECT COUNT(*) FROM scrobble_queue WHERE sent = 0",
[],
|row| row.get::<_, i64>(0),
)
.map(|count| count as u32)
.map_err(|e| format!("Failed to count queued scrobbles: {}", e))
}
}
#[cfg(test)]
@@ -217,6 +347,41 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn scrobble_queue_round_trips_and_marks_sent() {
let dir = unique_test_dir("offline-store-scrobbles");
let store = OfflineModeStore::new_at(&dir).unwrap();
let id1 = store
.queue_scrobble("Artist A", "Track 1", Some("Album X"), 1000)
.unwrap();
let id2 = store.queue_scrobble("Artist B", "Track 2", None, 2000).unwrap();
assert_ne!(id1, id2);
assert_eq!(store.queued_scrobble_count().unwrap(), 2);
let pending = store.get_queued_scrobbles(50).unwrap();
assert_eq!(pending.len(), 2);
// Oldest first.
assert_eq!(pending[0].timestamp, 1000);
assert_eq!(pending[0].artist, "Artist A");
assert_eq!(pending[0].album.as_deref(), Some("Album X"));
assert_eq!(pending[1].album, None);
store.mark_scrobbles_sent(&[pending[0].id]).unwrap();
assert_eq!(store.queued_scrobble_count().unwrap(), 1);
let remaining = store.get_queued_scrobbles(50).unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].timestamp, 2000);
// Cleanup only touches SENT rows older than the cutoff — the fresh
// unsent row always survives (the just-sent row's created_at is "now",
// so it is not older than any cutoff either).
let _ = store.cleanup_sent_scrobbles(7).unwrap();
assert_eq!(store.queued_scrobble_count().unwrap(), 1);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn stream_first_snapshot_round_trips() {
let dir = unique_test_dir("offline-store-snapshot");
+1
View File
@@ -9,5 +9,6 @@ pub mod library;
pub mod playback;
pub mod plex;
pub mod remote_control;
pub mod scrobblers;
pub mod subscription;
pub mod tray;
+462
View File
@@ -0,0 +1,462 @@
//! Portable scrobbler credential storage (Last.fm + ListenBrainz).
//!
//! Owns the persisted scrobbler settings for the Slint frontend, the part the
//! Tauri build kept in per-user localStorage:
//! - the master enable + collapse UI flags,
//! - Last.fm: session key + username + per-service enable flag
//! (replaces the `qbz-lastfm-session-key` / `qbz-lastfm-scrobbling`
//! localStorage keys — webview localStorage is unreachable from Slint,
//! so Last.fm credentials cannot be shared with the Tauri build),
//! - ListenBrainz: token + username + per-service enable flag. The token is
//! ALSO written through to the shared `ListenBrainzCache.credentials` row
//! by the Slint `scrobble` controller, so LB credentials DO stay shared
//! with the Tauri build; this copy is the Slint UI's fast read.
//!
//! The Last.fm offline queue does NOT live here: it is the `scrobble_queue`
//! table in the shared per-user `offline_settings.db`
//! ([`crate::offline_mode::store::OfflineModeStore`]) — same rows Tauri queues
//! into and flushes from. The ListenBrainz offline queue is the
//! `ListenBrainzCache.listen_queue` (qbz-integrations).
//!
//! Mirrors the [`super::plex`] store: a single-row SQLite settings table
//! re-pointed at the active user's data directory via
//! [`ScrobblerSettingsState::init_at`] at login, so credentials are scoped per
//! Qobuz user.
//!
//! Runtime concerns — the auth flows, the now-playing + scrobble fire, the
//! `min(50% of duration, 240s)` timer, the offline flush — live in the Slint
//! `scrobble` controller and call the `qbz-integrations` clients directly.
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScrobblerSettings {
/// Master toggle. When false, the whole scrobblers section body is hidden.
/// Default OFF — integrations are opt-in.
pub enabled: bool,
/// Collapse chevron state. Body renders only when `enabled && !ui_collapsed`.
pub ui_collapsed: bool,
// --- Last.fm ---
/// Per-service scrobbling enable flag (replaces `qbz-lastfm-scrobbling`).
pub lastfm_enabled: bool,
/// Last.fm session key (`get_session().key`). Empty = not authenticated.
pub lastfm_session_key: String,
/// Last.fm username (`LastFmSession.name`), for the "Signed in as …" label.
pub lastfm_username: String,
// --- ListenBrainz ---
/// Per-service enable flag.
pub listenbrainz_enabled: bool,
/// ListenBrainz user token. Empty = not authenticated.
pub listenbrainz_token: String,
/// ListenBrainz username (`UserInfo.user_name`).
pub listenbrainz_username: String,
}
impl ScrobblerSettings {
/// Last.fm has credentials (independent of the enable flags).
pub fn lastfm_is_authed(&self) -> bool {
!self.lastfm_session_key.is_empty()
}
/// ListenBrainz has credentials (independent of the enable flags).
pub fn listenbrainz_is_authed(&self) -> bool {
!self.listenbrainz_token.is_empty()
}
/// Last.fm should actually scrobble: master + service on + authed.
pub fn lastfm_active(&self) -> bool {
self.enabled && self.lastfm_enabled && self.lastfm_is_authed()
}
/// ListenBrainz should actually scrobble: master + service on + authed.
pub fn listenbrainz_active(&self) -> bool {
self.enabled && self.listenbrainz_enabled && self.listenbrainz_is_authed()
}
}
pub struct ScrobblerSettingsStore {
conn: Connection,
}
impl ScrobblerSettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open scrobbler settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for scrobbler settings database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS 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 ''
);",
)
.map_err(|e| format!("Failed to create scrobbler settings table: {}", e))?;
conn.execute(
"INSERT OR IGNORE INTO scrobbler_settings
(id, enabled, ui_collapsed, lastfm_enabled, lastfm_session_key,
lastfm_username, listenbrainz_enabled, listenbrainz_token,
listenbrainz_username)
VALUES (1, 0, 0, 0, '', '', 0, '', '')",
[],
)
.map_err(|e| format!("Failed to insert default scrobbler settings: {}", e))?;
info!("[ScrobblerSettings] Database initialized");
Ok(Self { conn })
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "scrobbler_settings.db")
}
pub fn get_settings(&self) -> Result<ScrobblerSettings, String> {
self.conn
.query_row(
"SELECT enabled, ui_collapsed, lastfm_enabled, lastfm_session_key,
lastfm_username, listenbrainz_enabled, listenbrainz_token,
listenbrainz_username
FROM scrobbler_settings WHERE id = 1",
[],
|row| {
let enabled: i32 = row.get(0)?;
let ui_collapsed: i32 = row.get(1)?;
let lastfm_enabled: i32 = row.get(2)?;
let lastfm_session_key: String = row.get(3)?;
let lastfm_username: String = row.get(4)?;
let listenbrainz_enabled: i32 = row.get(5)?;
let listenbrainz_token: String = row.get(6)?;
let listenbrainz_username: String = row.get(7)?;
Ok(ScrobblerSettings {
enabled: enabled != 0,
ui_collapsed: ui_collapsed != 0,
lastfm_enabled: lastfm_enabled != 0,
lastfm_session_key,
lastfm_username,
listenbrainz_enabled: listenbrainz_enabled != 0,
listenbrainz_token,
listenbrainz_username,
})
},
)
.map_err(|e| format!("Failed to get scrobbler settings: {}", e))
}
pub fn set_enabled(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE scrobbler_settings SET enabled = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set scrobbler enabled: {}", e))?;
Ok(())
}
pub fn set_ui_collapsed(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE scrobbler_settings SET ui_collapsed = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set scrobbler ui_collapsed: {}", e))?;
Ok(())
}
// --- Last.fm ---
pub fn set_lastfm_enabled(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE scrobbler_settings SET lastfm_enabled = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set lastfm_enabled: {}", e))?;
Ok(())
}
/// Persist the Last.fm session key + username together (after `get_session`).
pub fn set_lastfm_session(&self, key: &str, username: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE scrobbler_settings
SET lastfm_session_key = ?1, lastfm_username = ?2 WHERE id = 1",
params![key.trim(), username.trim()],
)
.map_err(|e| format!("Failed to set lastfm session: {}", e))?;
Ok(())
}
/// Sign out of Last.fm: clear key + username, keep `lastfm_enabled` flag.
pub fn disconnect_lastfm(&self) -> Result<(), String> {
self.conn
.execute(
"UPDATE scrobbler_settings
SET lastfm_session_key = '', lastfm_username = '' WHERE id = 1",
[],
)
.map_err(|e| format!("Failed to disconnect Last.fm: {}", e))?;
Ok(())
}
// --- ListenBrainz ---
pub fn set_listenbrainz_enabled(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE scrobbler_settings SET listenbrainz_enabled = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set listenbrainz_enabled: {}", e))?;
Ok(())
}
/// Persist the ListenBrainz token + username together (after `set_token`).
pub fn set_listenbrainz_token(&self, token: &str, username: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE scrobbler_settings
SET listenbrainz_token = ?1, listenbrainz_username = ?2 WHERE id = 1",
params![token.trim(), username.trim()],
)
.map_err(|e| format!("Failed to set listenbrainz token: {}", e))?;
Ok(())
}
/// Sign out of ListenBrainz: clear token + username, keep enable flag.
pub fn disconnect_listenbrainz(&self) -> Result<(), String> {
self.conn
.execute(
"UPDATE scrobbler_settings
SET listenbrainz_token = '', listenbrainz_username = '' WHERE id = 1",
[],
)
.map_err(|e| format!("Failed to disconnect ListenBrainz: {}", e))?;
Ok(())
}
}
pub struct ScrobblerSettingsState {
pub store: Arc<Mutex<Option<ScrobblerSettingsStore>>>,
}
impl Default for ScrobblerSettingsState {
fn default() -> Self {
Self::new_empty()
}
}
impl ScrobblerSettingsState {
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = ScrobblerSettingsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock scrobbler settings store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock scrobbler settings store".to_string())?;
*guard = None;
Ok(())
}
fn with_store<T>(
&self,
f: impl FnOnce(&ScrobblerSettingsStore) -> Result<T, String>,
) -> Result<T, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock scrobbler settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
f(store)
}
pub fn get_settings(&self) -> Result<ScrobblerSettings, String> {
self.with_store(|s| s.get_settings())
}
pub fn set_enabled(&self, value: bool) -> Result<(), String> {
self.with_store(|s| s.set_enabled(value))
}
pub fn set_ui_collapsed(&self, value: bool) -> Result<(), String> {
self.with_store(|s| s.set_ui_collapsed(value))
}
pub fn set_lastfm_enabled(&self, value: bool) -> Result<(), String> {
self.with_store(|s| s.set_lastfm_enabled(value))
}
pub fn set_lastfm_session(&self, key: &str, username: &str) -> Result<(), String> {
self.with_store(|s| s.set_lastfm_session(key, username))
}
pub fn disconnect_lastfm(&self) -> Result<(), String> {
self.with_store(|s| s.disconnect_lastfm())
}
pub fn set_listenbrainz_enabled(&self, value: bool) -> Result<(), String> {
self.with_store(|s| s.set_listenbrainz_enabled(value))
}
pub fn set_listenbrainz_token(&self, token: &str, username: &str) -> Result<(), String> {
self.with_store(|s| s.set_listenbrainz_token(token, username))
}
pub fn disconnect_listenbrainz(&self) -> Result<(), String> {
self.with_store(|s| s.disconnect_listenbrainz())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
#[test]
fn scrobbler_settings_default_is_unconfigured() {
let s = ScrobblerSettings::default();
assert!(!s.enabled);
assert!(!s.ui_collapsed);
assert!(!s.lastfm_enabled);
assert!(s.lastfm_session_key.is_empty());
assert!(s.lastfm_username.is_empty());
assert!(!s.listenbrainz_enabled);
assert!(s.listenbrainz_token.is_empty());
assert!(s.listenbrainz_username.is_empty());
assert!(!s.lastfm_is_authed());
assert!(!s.listenbrainz_is_authed());
assert!(!s.lastfm_active());
assert!(!s.listenbrainz_active());
}
#[test]
fn scrobbler_store_returns_defaults() {
let dir = unique_test_dir("scrobbler-default");
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
let s = store.get_settings().expect("get settings");
assert!(!s.enabled);
assert!(!s.lastfm_is_authed());
assert!(!s.listenbrainz_is_authed());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn scrobbler_persists_all_fields() {
let dir = unique_test_dir("scrobbler-persist");
{
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
store.set_enabled(true).expect("enabled");
store.set_ui_collapsed(true).expect("collapsed");
store.set_lastfm_enabled(true).expect("lfm enabled");
store
.set_lastfm_session(" sk-123 ", " alice ")
.expect("lfm session");
store.set_listenbrainz_enabled(true).expect("lb enabled");
store
.set_listenbrainz_token(" tok-456 ", " bob ")
.expect("lb token");
}
let reopened = ScrobblerSettingsStore::new_at(&dir).expect("reopen store");
let s = reopened.get_settings().expect("get settings");
assert!(s.enabled);
assert!(s.ui_collapsed);
assert!(s.lastfm_enabled);
// set_lastfm_session trims whitespace.
assert_eq!(s.lastfm_session_key, "sk-123");
assert_eq!(s.lastfm_username, "alice");
assert!(s.listenbrainz_enabled);
assert_eq!(s.listenbrainz_token, "tok-456");
assert_eq!(s.listenbrainz_username, "bob");
assert!(s.lastfm_active());
assert!(s.listenbrainz_active());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn scrobbler_disconnect_keeps_enable_flags() {
let dir = unique_test_dir("scrobbler-disconnect");
let store = ScrobblerSettingsStore::new_at(&dir).expect("open store");
store.set_enabled(true).expect("enabled");
store.set_lastfm_enabled(true).expect("lfm enabled");
store.set_lastfm_session("sk", "alice").expect("lfm session");
store.set_listenbrainz_enabled(true).expect("lb enabled");
store.set_listenbrainz_token("tok", "bob").expect("lb token");
store.disconnect_lastfm().expect("disconnect lfm");
store.disconnect_listenbrainz().expect("disconnect lb");
let s = store.get_settings().expect("get settings");
// Creds cleared.
assert!(s.lastfm_session_key.is_empty());
assert!(s.lastfm_username.is_empty());
assert!(s.listenbrainz_token.is_empty());
assert!(s.listenbrainz_username.is_empty());
// Enable flags preserved (so re-auth resumes scrobbling).
assert!(s.enabled);
assert!(s.lastfm_enabled);
assert!(s.listenbrainz_enabled);
// No longer authed -> not active.
assert!(!s.lastfm_active());
assert!(!s.listenbrainz_active());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn scrobbler_state_requires_init() {
let state = ScrobblerSettingsState::new_empty();
assert!(state.get_settings().is_err());
let dir = unique_test_dir("scrobbler-state");
state.init_at(&dir).expect("init at temp dir");
state
.set_lastfm_session("sk", "alice")
.expect("set session via state");
let s = state.get_settings().expect("get via state");
assert!(s.lastfm_is_authed());
assert_eq!(s.lastfm_username, "alice");
state.teardown().expect("teardown");
assert!(state.get_settings().is_err());
let _ = std::fs::remove_dir_all(dir);
}
}
+86
View File
@@ -78,6 +78,8 @@ mod plex_settings;
mod playlist_picker;
mod quality;
mod recently;
mod scrobble;
mod scrobbler_settings;
mod search;
mod settings;
mod share;
@@ -136,6 +138,14 @@ fn init_shell_for_user(
// Slint-only). Seeded into PlexSettingsState lazily on panel open.
plex_settings::init_for_user(user_id);
// Bind scrobbler (Last.fm + ListenBrainz) settings to this user (per-user
// scrobbler_settings.db), then start the scrobble runtime: tokio handle
// for the source-agnostic now-playing/scrobble fire, LB credential seed
// from the shared cache, and the offline-queue flush watcher (drains the
// shared scrobble_queue + listen_queue on every offline -> online edge).
scrobbler_settings::init_for_user(user_id);
scrobble::start(tokio::runtime::Handle::current());
// Bind "My QBZ" nav branding (custom label + icon) to this user
// (per-user myqbz_branding.json). Seeded into MyQbzBrandingState by the
// caller so the sidebar row + Settings row reflect the persisted values.
@@ -7088,6 +7098,82 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.on_clear_cache(move || plex_auth::clear_cache(weak.clone(), handle.clone()));
}
// Settings > Integrations — scrobblers (Last.fm + ListenBrainz). The auth
// flows + the now-playing/scrobble fire live in `scrobble`; the persisted
// store is the per-user `scrobbler_settings.db`.
{
let weak = window.as_weak();
window
.global::<ScrobbleActions>()
.on_load(move || scrobble::load(weak.clone()));
}
{
let weak = window.as_weak();
window
.global::<ScrobbleActions>()
.on_enable_toggle(move |b| scrobble::enable_toggle(weak.clone(), b));
}
{
window
.global::<ScrobbleActions>()
.on_collapse_toggle(move |b| scrobble::collapse_toggle(b));
}
{
let weak = window.as_weak();
window
.global::<ScrobbleActions>()
.on_lastfm_enable_toggle(move |b| scrobble::lastfm_enable_toggle(weak.clone(), b));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<ScrobbleActions>()
.on_lastfm_connect(move || scrobble::lastfm_connect(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
window
.global::<ScrobbleActions>()
.on_lastfm_open_auth_url(move || scrobble::lastfm_open_auth_url(weak.clone()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<ScrobbleActions>()
.on_lastfm_confirm(move || scrobble::lastfm_confirm(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
window
.global::<ScrobbleActions>()
.on_lastfm_disconnect(move || scrobble::lastfm_disconnect(weak.clone()));
}
{
let weak = window.as_weak();
window
.global::<ScrobbleActions>()
.on_listenbrainz_enable_toggle(move |b| {
scrobble::listenbrainz_enable_toggle(weak.clone(), b)
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<ScrobbleActions>()
.on_listenbrainz_set_token(move |tok| {
scrobble::listenbrainz_set_token(weak.clone(), handle.clone(), tok.to_string())
});
}
{
let weak = window.as_weak();
window
.global::<ScrobbleActions>()
.on_listenbrainz_disconnect(move || scrobble::listenbrainz_disconnect(weak.clone()));
}
// Tag editor (local album metadata) — open via on_media_action("album",
// "edit"); these wire the modal's own actions.
{
+13
View File
@@ -1415,12 +1415,25 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
sample_rate: track.sample_rate,
art_url: notify_art,
};
// Source-agnostic scrobbling (Last.fm + ListenBrainz). Fires on the
// SAME de-duped track-change edge as the notification, so resume/seek
// (which also re-run this fn) do NOT re-fire. Feeds the normalized
// QueueTrack text (Qobuz, local, AND Plex) with the version-enriched
// title (#360 parity). Skipped when a remote QConnect renderer drives
// playback — never scrobble a peer's audio.
let scrobble_meta = crate::scrobble::ScrobbleMeta {
artist: artist.clone(),
track: title.clone(),
album: (!album.is_empty()).then(|| album.clone()),
duration_secs: duration,
};
tokio::spawn(async move {
if let Some(svc) = crate::qconnect_service::service() {
if svc.is_peer_active().await {
return;
}
}
crate::scrobble::on_track_changed(scrobble_meta);
qbz_media_controls::show_track_notification(notify_meta, offline).await;
});
}
+772
View File
@@ -0,0 +1,772 @@
//! Settings > Integrations — scrobbler (Last.fm + ListenBrainz) auth controller
//! AND the source-agnostic now-playing / scrobble fire.
//!
//! Source-agnostic by construction: the fire path reads the CURRENT
//! `qbz_models::QueueTrack`'s already-normalized `artist` / `album` / title
//! (Qobuz, local, AND Plex all funnel through it) and feeds plain text — no
//! artwork (Last.fm has no image field; ListenBrainz takes only optional MB
//! IDs / ISRC / duration). The clients live in `qbz-integrations` and are
//! called directly (no Tauri command seam).
//!
//! Two firing edges (mirrors the Svelte `playbackService.ts`):
//! - now-playing: fires immediately on a track-change edge (skipped offline).
//! - scrobble: armed at `min(50% of duration, 240s)` after the change; a
//! monotonic `SCROBBLE_GEN` guard self-cancels a stale timer if the track
//! changed before it fires (the Svelte `clearTimeout` equivalent). Like
//! Tauri, pause does NOT stop the clock.
//!
//! Offline behavior: engine offline OR call failure queues the scrobble —
//! Last.fm into the SHARED per-user `offline_settings.db` `scrobble_queue`
//! (same rows Tauri queues/flushes), ListenBrainz into the SHARED per-user
//! `listenbrainz_v2.db` `listen_queue`. A watcher on the offline-mode engine
//! drains both queues on every offline -> online edge (manual-flag exits
//! included), plus once at shell entry.
//!
//! Persistence lives in `crate::scrobbler_settings` (the per-user
//! `scrobbler_settings.db`); the auth flows seed/clear it. ListenBrainz
//! credentials are ALSO written through to the shared `ListenBrainzCache`
//! credentials row, so the Tauri build sees the same sign-in (and a Tauri
//! sign-in seeds this build at shell entry).
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use slint::{ComponentHandle, Weak};
use qbz_app::offline_mode::OfflineModeStore;
use qbz_integrations::listenbrainz::cache::ListenBrainzCache;
use qbz_integrations::listenbrainz::AdditionalInfo;
use qbz_integrations::{LastFmClient, ListenBrainzClient};
use crate::scrobbler_settings;
use crate::{AppWindow, ScrobbleState};
// ----------------------------------------------------------------------------
// Status helper — Slint uses inline @tr, so we resolve to a plain English
// label Rust-side. `kind`: 0 none, 1 info, 2 connected/ok, 3 error. Mirrors
// `plex_auth::set_status`.
// ----------------------------------------------------------------------------
fn set_status(weak: &Weak<AppWindow>, text: String, kind: i32) {
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<ScrobbleState>();
s.set_status_text(text.into());
s.set_status_kind(kind);
});
}
// ----------------------------------------------------------------------------
// Last.fm pending-token bridge. `get_token` returns a request token the user
// authorizes in the browser; `get_session` (the confirm step) exchanges it.
// The two steps are separate UI callbacks, so the token is stashed here.
// ----------------------------------------------------------------------------
static LASTFM_PENDING_TOKEN: Mutex<Option<String>> = Mutex::new(None);
// ----------------------------------------------------------------------------
// Tokio runtime handle — captured at shell entry so the fire path (which runs
// from the playback poll, not a UI callback) can spawn network tasks.
// ----------------------------------------------------------------------------
static RT_HANDLE: Mutex<Option<tokio::runtime::Handle>> = Mutex::new(None);
fn rt_handle() -> Option<tokio::runtime::Handle> {
RT_HANDLE.lock().ok().and_then(|g| g.clone())
}
/// One-shot guard for the engine-watch flush task (lives for the process).
static FLUSH_WATCHER: OnceLock<()> = OnceLock::new();
/// Per-user runtime start, called from `init_shell_for_user` AFTER
/// `scrobbler_settings::init_for_user`. Captures the tokio handle for the
/// fire path, seeds ListenBrainz credentials from the SHARED cache when this
/// build has none (a Tauri sign-in carries over), starts the offline-engine
/// flush watcher (once per process), and kicks an initial queue flush.
pub fn start(handle: tokio::runtime::Handle) {
if let Ok(mut g) = RT_HANDLE.lock() {
*g = Some(handle.clone());
}
handle.spawn(async move {
seed_listenbrainz_from_shared_cache().await;
if !crate::offline_mode::engine().is_offline() {
flush_offline_queues().await;
}
});
let watcher_handle = handle.clone();
FLUSH_WATCHER.get_or_init(move || {
watcher_handle.spawn(async move {
let mut rx = crate::offline_mode::engine().subscribe();
let mut was_offline = rx.borrow_and_update().is_offline();
loop {
if rx.changed().await.is_err() {
break;
}
let offline = rx.borrow_and_update().is_offline();
if was_offline && !offline {
log::info!("[qbz-slint] scrobblers: back online, flushing queues");
flush_offline_queues().await;
}
was_offline = offline;
}
});
});
}
/// Adopt the shared `ListenBrainzCache` credentials (the row the Tauri build
/// persists to) when this build's store has no LB token yet. Enable flags are
/// NOT touched — scrobbling stays opt-in per build.
async fn seed_listenbrainz_from_shared_cache() {
if scrobbler_settings::get().listenbrainz_is_authed() {
return;
}
let Some(path) = listenbrainz_cache_path() else {
return;
};
let creds = tokio::task::spawn_blocking(move || {
ListenBrainzCache::new(&path).and_then(|c| c.get_credentials())
})
.await;
if let Ok(Ok((Some(token), Some(user_name)))) = creds {
if !token.is_empty() {
log::info!("[qbz-slint] adopting ListenBrainz credentials from shared cache");
scrobbler_settings::set_listenbrainz_token(&token, &user_name);
}
}
}
// ============================================================================
// Auth + settings callbacks (bound in main.rs).
// ============================================================================
/// Panel init: seed `ScrobbleState` from the persisted store.
pub fn load(weak: Weak<AppWindow>) {
let cfg = scrobbler_settings::get();
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<ScrobbleState>();
s.set_enabled(cfg.enabled);
s.set_ui_collapsed(cfg.ui_collapsed);
s.set_lastfm_enabled(cfg.lastfm_enabled);
s.set_lastfm_authed(cfg.lastfm_is_authed());
s.set_lastfm_username(cfg.lastfm_username.clone().into());
s.set_lastfm_auth_url("".into());
s.set_lastfm_busy(false);
s.set_listenbrainz_enabled(cfg.listenbrainz_enabled);
s.set_listenbrainz_authed(cfg.listenbrainz_is_authed());
s.set_listenbrainz_username(cfg.listenbrainz_username.clone().into());
s.set_listenbrainz_token_input("".into());
s.set_listenbrainz_busy(false);
s.set_status_text("".into());
s.set_status_kind(0);
});
}
pub fn enable_toggle(weak: Weak<AppWindow>, enabled: bool) {
scrobbler_settings::set_enabled(enabled);
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<ScrobbleState>().set_enabled(enabled);
});
}
pub fn collapse_toggle(collapsed: bool) {
scrobbler_settings::set_ui_collapsed(collapsed);
}
// --- Last.fm -----------------------------------------------------------------
pub fn lastfm_enable_toggle(weak: Weak<AppWindow>, enabled: bool) {
scrobbler_settings::set_lastfm_enabled(enabled);
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<ScrobbleState>().set_lastfm_enabled(enabled);
});
}
/// Step 1: request a token, open the Last.fm authorize URL in the browser, and
/// reveal the "Finish" affordance. Mirrors the Svelte `v2_lastfm_get_auth_url`
/// + open path (system browser, like `plex_auth::open_auth_url`).
pub fn lastfm_connect(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let _ = weak.upgrade_in_event_loop(|w| w.global::<ScrobbleState>().set_lastfm_busy(true));
handle.spawn(async move {
let client = LastFmClient::new();
match client.get_token().await {
Ok((token, auth_url)) => {
if let Ok(mut g) = LASTFM_PENDING_TOKEN.lock() {
*g = Some(token);
}
let auth_url_ui = auth_url.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<ScrobbleState>();
s.set_lastfm_busy(false);
s.set_lastfm_auth_url(auth_url_ui.into());
});
// Open the browser to authorize.
if let Err(e) = open::that(&auth_url) {
log::warn!("[qbz-slint] open Last.fm auth url failed: {e}");
}
set_status(
&weak,
"Authorize QBZ in your browser, then click \"Finish\"".to_string(),
1,
);
}
Err(e) => {
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<ScrobbleState>().set_lastfm_busy(false);
});
set_status(&weak, format!("Error: {e}"), 3);
crate::toast::error_weak(&weak, "Last.fm sign-in failed to start");
}
}
});
}
/// Re-open the stored authorize URL (in case the browser did not launch).
pub fn lastfm_open_auth_url(weak: Weak<AppWindow>) {
let url = weak
.upgrade()
.map(|w| w.global::<ScrobbleState>().get_lastfm_auth_url().to_string())
.unwrap_or_default();
if url.is_empty() {
return;
}
if let Err(e) = open::that(&url) {
log::warn!("[qbz-slint] open Last.fm auth url failed: {e}");
}
}
/// Step 2 (the user clicked "Finish" after authorizing): exchange the pending
/// token for a session key + username and persist it. Mirrors
/// `v2_lastfm_complete_auth`.
pub fn lastfm_confirm(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let token = LASTFM_PENDING_TOKEN.lock().ok().and_then(|g| g.clone());
let Some(token) = token else {
set_status(&weak, "Start the sign-in first".to_string(), 3);
return;
};
let _ = weak.upgrade_in_event_loop(|w| w.global::<ScrobbleState>().set_lastfm_busy(true));
handle.spawn(async move {
let mut client = LastFmClient::new();
match client.get_session(&token).await {
Ok(session) => {
scrobbler_settings::set_lastfm_session(&session.key, &session.name);
// Default the per-service flag ON the first time we connect, so
// scrobbling starts without an extra toggle.
if !scrobbler_settings::get().lastfm_enabled {
scrobbler_settings::set_lastfm_enabled(true);
}
if let Ok(mut g) = LASTFM_PENDING_TOKEN.lock() {
*g = None;
}
let username = session.name.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<ScrobbleState>();
s.set_lastfm_busy(false);
s.set_lastfm_authed(true);
s.set_lastfm_enabled(true);
s.set_lastfm_username(username.into());
s.set_lastfm_auth_url("".into());
});
set_status(&weak, format!("Connected as {}", session.name), 2);
}
Err(e) => {
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<ScrobbleState>().set_lastfm_busy(false);
});
set_status(
&weak,
format!("Error: {e} (did you authorize in the browser?)"),
3,
);
}
}
});
}
pub fn lastfm_disconnect(weak: Weak<AppWindow>) {
scrobbler_settings::disconnect_lastfm();
if let Ok(mut g) = LASTFM_PENDING_TOKEN.lock() {
*g = None;
}
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<ScrobbleState>();
s.set_lastfm_authed(false);
s.set_lastfm_username("".into());
s.set_lastfm_auth_url("".into());
s.set_lastfm_busy(false);
});
set_status(&weak, "Last.fm disconnected".to_string(), 1);
}
// --- ListenBrainz ------------------------------------------------------------
pub fn listenbrainz_enable_toggle(weak: Weak<AppWindow>, enabled: bool) {
scrobbler_settings::set_listenbrainz_enabled(enabled);
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<ScrobbleState>().set_listenbrainz_enabled(enabled);
});
}
/// Save + validate a ListenBrainz user token. Mirrors `v2_listenbrainz_connect`
/// — validated against `/validate-token`, then persisted to this build's store
/// AND the shared `ListenBrainzCache` (so the Tauri build picks it up).
pub fn listenbrainz_set_token(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, token: String) {
let token = token.trim().to_string();
if token.is_empty() {
set_status(&weak, "Paste your ListenBrainz user token first".to_string(), 3);
return;
}
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<ScrobbleState>().set_listenbrainz_busy(true)
});
handle.spawn(async move {
let client = ListenBrainzClient::new();
match client.set_token(&token).await {
Ok(info) => {
scrobbler_settings::set_listenbrainz_token(&token, &info.user_name);
if !scrobbler_settings::get().listenbrainz_enabled {
scrobbler_settings::set_listenbrainz_enabled(true);
}
// Write-through to the shared cache (Tauri reads it at session
// start). Best-effort.
if let Some(path) = listenbrainz_cache_path() {
let tok = token.clone();
let name = info.user_name.clone();
let _ = tokio::task::spawn_blocking(move || {
ListenBrainzCache::new(&path)
.and_then(|c| c.save_credentials(&tok, &name))
})
.await;
}
let username = info.user_name.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<ScrobbleState>();
s.set_listenbrainz_busy(false);
s.set_listenbrainz_authed(true);
s.set_listenbrainz_enabled(true);
s.set_listenbrainz_username(username.into());
s.set_listenbrainz_token_input("".into());
});
set_status(&weak, format!("Connected as {}", info.user_name), 2);
}
Err(e) => {
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<ScrobbleState>().set_listenbrainz_busy(false);
});
set_status(&weak, format!("Error: {e}"), 3);
}
}
});
}
pub fn listenbrainz_disconnect(weak: Weak<AppWindow>) {
scrobbler_settings::disconnect_listenbrainz();
// Clear the shared cache credentials too (mirrors Tauri's disconnect).
if let Some(path) = listenbrainz_cache_path() {
if let Some(handle) = rt_handle() {
handle.spawn(async move {
let _ = tokio::task::spawn_blocking(move || {
ListenBrainzCache::new(&path).and_then(|c| c.clear_credentials())
})
.await;
});
}
}
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<ScrobbleState>();
s.set_listenbrainz_authed(false);
s.set_listenbrainz_username("".into());
s.set_listenbrainz_token_input("".into());
s.set_listenbrainz_busy(false);
});
set_status(&weak, "ListenBrainz disconnected".to_string(), 1);
}
// ============================================================================
// Fire + schedule (source-agnostic; called from `refresh_now_playing_meta`).
// ============================================================================
/// Normalized track facts the fire path needs. Built from the CURRENT
/// `QueueTrack` on the de-duped track-change edge; the title is the
/// version-enriched display title so remixes/editions scrobble correctly
/// (issue #360 parity with the Svelte `formatTrackTitle` path).
#[derive(Clone)]
pub struct ScrobbleMeta {
pub artist: String,
pub track: String,
/// `None` when empty — clients take `Option<&str>` for album.
pub album: Option<String>,
pub duration_secs: u64,
}
/// Monotonic generation, bumped on every track change so a delayed scrobble
/// timer that fires after the user skipped is dropped (the Svelte
/// `clearTimeout` equivalent). Like Tauri, pause/stop do NOT cancel it.
static SCROBBLE_GEN: AtomicU64 = AtomicU64::new(0);
/// `min(50% of duration, 240s)` in seconds — the Last.fm rule, applied to both
/// services (matches the Svelte `Math.min(durationSecs * 0.5, 240) * 1000`).
fn scrobble_delay_secs(duration_secs: u64) -> u64 {
(duration_secs / 2).min(240)
}
/// Track-change entry point. Fires now-playing immediately for each enabled +
/// authed service, then arms a delayed scrobble. No-op when no service is
/// active. Called from `refresh_now_playing_meta` on the de-duped track-change
/// edge (after the QConnect peer-active gate), so it is NOT re-armed on
/// resume/seek and never fires for a remote renderer's audio.
pub fn on_track_changed(meta: ScrobbleMeta) {
// Always bump the generation so any in-flight stale timer self-cancels.
let my_gen = SCROBBLE_GEN.fetch_add(1, Ordering::SeqCst) + 1;
let cfg = scrobbler_settings::get();
if !cfg.lastfm_active() && !cfg.listenbrainz_active() {
return;
}
let Some(handle) = rt_handle() else {
return;
};
handle.spawn(async move {
// Now-playing immediately (skipped while offline — needs network and
// is not worth queueing; matches the Svelte path).
if !crate::offline_mode::engine().is_offline() {
send_now_playing(&meta, &cfg).await;
}
// Delayed scrobble at min(dur/2, 240s).
let wait = scrobble_delay_secs(meta.duration_secs);
if wait > 0 {
tokio::time::sleep(Duration::from_secs(wait)).await;
}
// Self-cancel if a newer track change superseded us.
if SCROBBLE_GEN.load(Ordering::SeqCst) != my_gen {
return;
}
send_scrobble(&meta).await;
});
}
/// Optional ListenBrainz extras — duration is the only one the QueueTrack
/// carries (no ISRC / MB IDs on the queue model yet).
fn lb_info(duration_secs: u64) -> Option<AdditionalInfo> {
Some(AdditionalInfo {
duration_ms: (duration_secs > 0).then_some(duration_secs * 1000),
..Default::default()
})
}
/// Fire "now playing" for each enabled service. Failures only log — the
/// scrobble path is what queues.
async fn send_now_playing(meta: &ScrobbleMeta, cfg: &scrobbler_settings::ScrobblerSettings) {
let album = meta.album.as_deref();
if cfg.lastfm_active() {
let client = LastFmClient::with_session_key(cfg.lastfm_session_key.clone());
if let Err(e) = client
.update_now_playing(&meta.artist, &meta.track, album)
.await
{
log::debug!("[qbz-slint] Last.fm now-playing failed: {e}");
}
}
if cfg.listenbrainz_active() {
let client = ListenBrainzClient::new();
client
.restore_token(cfg.listenbrainz_token.clone(), cfg.listenbrainz_username.clone())
.await;
if let Err(e) = client
.submit_playing_now(&meta.artist, &meta.track, album, lb_info(meta.duration_secs))
.await
{
log::debug!("[qbz-slint] ListenBrainz now-playing failed: {e}");
}
}
}
/// 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`. Re-reads settings in case the user disconnected
/// while the timer waited.
async fn send_scrobble(meta: &ScrobbleMeta) {
let cfg = scrobbler_settings::get();
let album = meta.album.as_deref();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let offline = crate::offline_mode::engine().is_offline();
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!(
"[qbz-slint] Last.fm scrobbled: {} - {}",
meta.artist,
meta.track
);
true
}
Err(e) => {
log::warn!("[qbz-slint] Last.fm scrobble failed ({e}); queueing for later");
false
}
}
};
if !sent {
queue_lastfm(meta, timestamp).await;
}
}
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!(
"[qbz-slint] ListenBrainz scrobbled: {} - {}",
meta.artist,
meta.track
);
true
}
Err(e) => {
log::warn!(
"[qbz-slint] ListenBrainz scrobble failed ({e}); queueing for later"
);
false
}
}
};
if !sent {
queue_listenbrainz(meta, timestamp).await;
}
}
}
/// Queue a Last.fm scrobble into the SHARED per-user `offline_settings.db`
/// `scrobble_queue` (the table Tauri queues into and flushes from).
async fn queue_lastfm(meta: &ScrobbleMeta, timestamp: i64) {
let Some(dir) = scrobbler_settings::user_dir() else {
return;
};
let artist = meta.artist.clone();
let track = meta.track.clone();
let album = meta.album.clone();
let _ = tokio::task::spawn_blocking(move || {
match OfflineModeStore::new_at(&dir) {
Ok(store) => {
if let Err(e) = store.queue_scrobble(&artist, &track, album.as_deref(), timestamp)
{
log::warn!("[qbz-slint] queue Last.fm scrobble failed: {e}");
}
}
Err(e) => log::warn!("[qbz-slint] open offline settings store failed: {e}"),
}
})
.await;
}
/// Queue a ListenBrainz listen into the SHARED per-user
/// `ListenBrainzCache.listen_queue` (the canonical LB offline store).
async fn queue_listenbrainz(meta: &ScrobbleMeta, timestamp: i64) {
let Some(path) = listenbrainz_cache_path() else {
return;
};
let artist = meta.artist.clone();
let track = meta.track.clone();
let album = meta.album.clone();
let duration_ms = (meta.duration_secs > 0).then_some(meta.duration_secs * 1000);
let _ = tokio::task::spawn_blocking(move || match ListenBrainzCache::new(&path) {
Ok(cache) => {
if let Err(e) = cache.queue_listen(
timestamp,
&artist,
&track,
album.as_deref(),
None,
None,
None,
None,
duration_ms,
) {
log::warn!("[qbz-slint] queue ListenBrainz listen failed: {e}");
}
}
Err(e) => log::warn!("[qbz-slint] open ListenBrainz cache failed: {e}"),
})
.await;
}
/// `<user_dir>/cache/listenbrainz_v2.db` — the SAME per-user file Tauri's
/// `ListenBrainzV2State::init_cache_at` opens, so credentials and the offline
/// listen queue are shared across frontends.
fn listenbrainz_cache_path() -> Option<PathBuf> {
let dir = scrobbler_settings::user_dir()?.join("cache");
std::fs::create_dir_all(&dir).ok()?;
Some(dir.join("listenbrainz_v2.db"))
}
// ============================================================================
// Offline flush — drain both queues (shell entry + every offline->online edge).
// ============================================================================
async fn flush_offline_queues() {
flush_lastfm_queue().await;
flush_listenbrainz_queue().await;
}
/// Flush the Last.fm queue: up to 50 per pass (the Last.fm batch limit),
/// oldest first; entries older than 14 days are dropped (marked sent) since
/// Last.fm rejects them — both mirror the Svelte `flushScrobbleQueue`. Stops
/// at the first network failure (still offline / flaky) and retries on the
/// next edge. Cleans up sent rows older than 7 days afterwards.
async fn flush_lastfm_queue() {
let cfg = scrobbler_settings::get();
if !cfg.lastfm_is_authed() {
return;
}
let Some(dir) = scrobbler_settings::user_dir() else {
return;
};
let pending = match tokio::task::spawn_blocking({
let dir = dir.clone();
move || OfflineModeStore::new_at(&dir).and_then(|s| s.get_queued_scrobbles(50))
})
.await
{
Ok(Ok(p)) => p,
_ => return,
};
if pending.is_empty() {
return;
}
let client = LastFmClient::with_session_key(cfg.lastfm_session_key.clone());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let cutoff = now - 14 * 86400;
let mut sent_ids: Vec<i64> = Vec::new();
for item in pending {
if item.timestamp < cutoff {
// Too old for Last.fm — drop it (mark sent so it stops re-trying).
sent_ids.push(item.id);
continue;
}
match client
.scrobble(
&item.artist,
&item.track,
item.album.as_deref(),
item.timestamp as u64,
)
.await
{
Ok(()) => sent_ids.push(item.id),
Err(e) => {
log::warn!(
"[qbz-slint] Last.fm flush stopped at {} - {}: {e}",
item.artist,
item.track
);
break; // still offline / failing — retry on the next edge
}
}
}
if !sent_ids.is_empty() {
let count = sent_ids.len();
let _ = tokio::task::spawn_blocking(move || {
OfflineModeStore::new_at(&dir).and_then(|s| {
s.mark_scrobbles_sent(&sent_ids)?;
s.cleanup_sent_scrobbles(7)
})
})
.await;
log::info!("[qbz-slint] Last.fm flush: {count} scrobble(s) sent/cleared");
}
}
/// Flush the ListenBrainz queue from the shared cache. Stops at the first
/// failure and retries on the next edge.
async fn flush_listenbrainz_queue() {
let cfg = scrobbler_settings::get();
if !cfg.listenbrainz_is_authed() {
return;
}
let Some(path) = listenbrainz_cache_path() else {
return;
};
let pending = match tokio::task::spawn_blocking({
let path = path.clone();
move || ListenBrainzCache::new(&path).and_then(|c| c.get_pending_listens(500))
})
.await
{
Ok(Ok(p)) => p,
_ => return,
};
if pending.is_empty() {
return;
}
let client = ListenBrainzClient::new();
client
.restore_token(cfg.listenbrainz_token.clone(), cfg.listenbrainz_username.clone())
.await;
let mut sent_ids: Vec<i64> = Vec::new();
for item in pending {
let info = AdditionalInfo {
recording_mbid: item.recording_mbid.clone(),
release_mbid: item.release_mbid.clone(),
artist_mbids: item.artist_mbids.clone(),
isrc: item.isrc.clone(),
duration_ms: item.duration_ms,
..Default::default()
};
if client
.submit_listen(
&item.artist_name,
&item.track_name,
item.release_name.as_deref(),
item.listened_at,
Some(info),
)
.await
.is_ok()
{
sent_ids.push(item.id);
} else {
break; // still failing — retry on the next edge
}
}
if !sent_ids.is_empty() {
let count = sent_ids.len();
let _ = tokio::task::spawn_blocking(move || {
ListenBrainzCache::new(&path).and_then(|c| c.mark_listens_sent(&sent_ids))
})
.await;
log::info!("[qbz-slint] ListenBrainz flush: {count} listen(s) sent");
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Slint-side glue for scrobbler (Last.fm + ListenBrainz) settings.
//!
//! The persisted model + SQLite store live in the shared, frontend-agnostic
//! `qbz_app::settings::scrobblers` module (ADR-006). This module only owns a
//! process-global handle to that store, bound to the active user the same way
//! `plex_settings` binds the per-user `plex_settings.db`:
//!
//! <data_dir>/qbz/users/<user_id>/scrobbler_settings.db
//!
//! so credentials are scoped per Qobuz user. It also records the bound user
//! directory, which the `scrobble` controller needs to reach the two SHARED
//! per-user stores the offline queues live in (same files Tauri uses):
//!
//! <user_dir>/offline_settings.db — Last.fm `scrobble_queue`
//! <user_dir>/cache/listenbrainz_v2.db — LB credentials + `listen_queue`
//!
//! Runtime concerns — the auth flows, the now-playing + scrobble fire, and the
//! offline flush — live in the `scrobble` controller and call the
//! `qbz-integrations` clients directly.
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex};
pub use qbz_app::settings::scrobblers::{ScrobblerSettings, ScrobblerSettingsState};
/// Process-global scrobbler settings handle. Starts session-less (`new_empty`);
/// `init_for_user` binds it to the per-user DB on shell entry.
static STATE: LazyLock<ScrobblerSettingsState> =
LazyLock::new(ScrobblerSettingsState::new_empty);
/// The bound per-user data directory (`init_for_user`). The `scrobble`
/// controller resolves the shared offline-queue stores against it.
static USER_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
/// `<data_dir>/qbz/users/<user_id>/` — the per-user directory the shared store
/// appends `scrobbler_settings.db` to. Matches the Plex/tray per-user path.
fn user_dir_for(user_id: u64) -> Option<PathBuf> {
Some(
dirs::data_dir()?
.join("qbz")
.join("users")
.join(user_id.to_string()),
)
}
/// Bind the store to `user_id` on shell entry. Best-effort — failures are
/// logged and leave the store session-less (settings degrade to defaults).
pub fn init_for_user(user_id: u64) {
let Some(dir) = user_dir_for(user_id) else {
log::warn!("[qbz-slint] scrobbler settings: data dir unavailable, not initialized");
return;
};
if let Err(e) = STATE.init_at(&dir) {
log::error!("[qbz-slint] scrobbler settings init failed: {e}");
return;
}
if let Ok(mut guard) = USER_DIR.lock() {
*guard = Some(dir);
}
}
/// The active user's data dir, or `None` before shell entry.
pub fn user_dir() -> Option<PathBuf> {
USER_DIR.lock().ok().and_then(|g| g.clone())
}
/// Current persisted settings, or `ScrobblerSettings::default()` when there is
/// no active session / the read fails.
pub fn get() -> ScrobblerSettings {
STATE.get_settings().unwrap_or_default()
}
pub fn set_enabled(value: bool) {
if let Err(e) = STATE.set_enabled(value) {
log::error!("[qbz-slint] scrobbler settings set_enabled failed: {e}");
}
}
pub fn set_ui_collapsed(value: bool) {
if let Err(e) = STATE.set_ui_collapsed(value) {
log::error!("[qbz-slint] scrobbler settings set_ui_collapsed failed: {e}");
}
}
pub fn set_lastfm_enabled(value: bool) {
if let Err(e) = STATE.set_lastfm_enabled(value) {
log::error!("[qbz-slint] scrobbler settings set_lastfm_enabled failed: {e}");
}
}
pub fn set_lastfm_session(key: &str, username: &str) {
if let Err(e) = STATE.set_lastfm_session(key, username) {
log::error!("[qbz-slint] scrobbler settings set_lastfm_session failed: {e}");
}
}
pub fn disconnect_lastfm() {
if let Err(e) = STATE.disconnect_lastfm() {
log::error!("[qbz-slint] scrobbler settings disconnect_lastfm failed: {e}");
}
}
pub fn set_listenbrainz_enabled(value: bool) {
if let Err(e) = STATE.set_listenbrainz_enabled(value) {
log::error!("[qbz-slint] scrobbler settings set_listenbrainz_enabled failed: {e}");
}
}
pub fn set_listenbrainz_token(token: &str, username: &str) {
if let Err(e) = STATE.set_listenbrainz_token(token, username) {
log::error!("[qbz-slint] scrobbler settings set_listenbrainz_token failed: {e}");
}
}
pub fn disconnect_listenbrainz() {
if let Err(e) = STATE.disconnect_listenbrainz() {
log::error!("[qbz-slint] scrobbler settings disconnect_listenbrainz failed: {e}");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,269 @@
// Settings > Integrations — source-agnostic scrobblers (Last.fm + ListenBrainz).
// Mirrors the Plex section structure in LocalLibrarySettings.slint: a master
// enable + collapse header, then per-service connect/disconnect rows that bind
// the `ScrobbleState` global for state and `ScrobbleActions` for actions. The
// fire path is source-agnostic (Qobuz, local, AND Plex) and lives in Rust.
// ADR-008: no pills — plain bordered buttons.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { ScrobbleState, ScrobbleActions } from "../state.slint";
import { SettingRow } from "SettingRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { LineEdit } from "std-widgets.slint";
component GroupHeader inherits Text {
color: Theme.text-muted;
font-size: 11px;
letter-spacing: 1.5px;
font-weight: Typography.semibold;
}
// Matches OfflineSettings/LocalLibrarySettings SecondaryButton.
component SecondaryButton inherits Rectangle {
in property <string> label;
in property <bool> danger: false;
in property <bool> enabled: true;
callback clicked();
height: 32px;
width: row.preferred-width + 24px;
border-radius: Radius.sm;
border-width: 1px;
border-color: root.danger ? #e0564f : Theme.border-subtle;
background: !root.enabled
? transparent
: (ta.has-hover
? (root.danger ? #e0564f22 : Theme.surface-hover)
: Theme.surface-elevated);
opacity: root.enabled ? 1.0 : 0.45;
row := HorizontalLayout {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
Text {
text: root.label;
color: root.danger ? #e0564f : Theme.text-primary;
font-size: Typography.legal;
font-weight: Typography.semibold;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
export component IntegrationsSettings inherits VerticalLayout {
spacing: 4px;
init => {
ScrobbleActions.load();
}
// ===================================================================
// SCROBBLERS — master header: title + master toggle + collapse chevron.
// Body renders only when `enabled && !ui-collapsed`.
// ===================================================================
HorizontalLayout {
spacing: 10px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
HorizontalLayout {
spacing: 8px;
GroupHeader { text: @tr("SCROBBLERS"); vertical-alignment: center; }
if ScrobbleState.enabled: VerticalLayout {
alignment: center;
Rectangle {
width: 20px;
height: 20px;
border-radius: Radius.sm;
background: chev-ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: ScrobbleState.ui-collapsed
? @image-url("../assets/icons/chevron-right.svg")
: @image-url("../assets/icons/chevron-down.svg");
width: 14px;
height: 14px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
chev-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
ScrobbleState.ui-collapsed = !ScrobbleState.ui-collapsed;
ScrobbleActions.collapse-toggle(ScrobbleState.ui-collapsed);
}
}
}
}
}
Text {
text: @tr("Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks.");
color: Theme.text-muted;
font-size: 12px;
wrap: word-wrap;
}
}
VerticalLayout {
alignment: center;
QbzToggle {
checked: ScrobbleState.enabled;
toggled(v) => {
ScrobbleState.enabled = v;
ScrobbleActions.enable-toggle(v);
}
}
}
}
// Body — only when enabled and not collapsed.
if ScrobbleState.enabled && !ScrobbleState.ui-collapsed: VerticalLayout {
spacing: 4px;
Rectangle { height: 12px; }
// ----------------------------------------------------------------
// LAST.FM
// ----------------------------------------------------------------
GroupHeader { text: @tr("LAST.FM"); }
Rectangle { height: 4px; }
SettingRow {
label: @tr("Scrobble to Last.fm");
description: ScrobbleState.lastfm-authed
? @tr("Signed in as {}.", ScrobbleState.lastfm-username)
: @tr("Connect your Last.fm account to enable scrobbling.");
QbzToggle {
checked: ScrobbleState.lastfm-enabled;
toggled(v) => {
ScrobbleState.lastfm-enabled = v;
ScrobbleActions.lastfm-enable-toggle(v);
}
}
}
// Connect (when not authed) — two-step browser flow.
if !ScrobbleState.lastfm-authed: SettingRow {
label: @tr("Connect");
description: @tr("Authorize QBZ in your browser, then click Finish.");
HorizontalLayout {
spacing: 8px;
SecondaryButton {
label: ScrobbleState.lastfm-busy ? @tr("Working...") : @tr("Connect Last.fm");
enabled: !ScrobbleState.lastfm-busy;
clicked => { ScrobbleActions.lastfm-connect(); }
}
if ScrobbleState.lastfm-auth-url != "": SecondaryButton {
label: @tr("Open authorize page");
clicked => { ScrobbleActions.lastfm-open-auth-url(); }
}
if ScrobbleState.lastfm-auth-url != "": SecondaryButton {
label: ScrobbleState.lastfm-busy ? @tr("Working...") : @tr("Finish");
enabled: !ScrobbleState.lastfm-busy;
clicked => { ScrobbleActions.lastfm-confirm(); }
}
}
}
// Disconnect (when authed).
if ScrobbleState.lastfm-authed: SettingRow {
label: @tr("Disconnect Last.fm");
description: @tr("Sign out of Last.fm.");
SecondaryButton {
label: @tr("Disconnect");
danger: true;
clicked => { ScrobbleActions.lastfm-disconnect(); }
}
}
Rectangle { height: 18px; }
// ----------------------------------------------------------------
// LISTENBRAINZ
// ----------------------------------------------------------------
GroupHeader { text: @tr("LISTENBRAINZ"); }
Rectangle { height: 4px; }
SettingRow {
label: @tr("Scrobble to ListenBrainz");
description: ScrobbleState.listenbrainz-authed
? @tr("Signed in as {}.", ScrobbleState.listenbrainz-username)
: @tr("Paste your ListenBrainz user token to enable scrobbling.");
QbzToggle {
checked: ScrobbleState.listenbrainz-enabled;
toggled(v) => {
ScrobbleState.listenbrainz-enabled = v;
ScrobbleActions.listenbrainz-enable-toggle(v);
}
}
}
// Token field (when not authed).
if !ScrobbleState.listenbrainz-authed: SettingRow {
label: @tr("User token");
description: @tr("From listenbrainz.org/settings.");
HorizontalLayout {
width: 240px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
LineEdit {
text: ScrobbleState.listenbrainz-token-input;
placeholder-text: @tr("ListenBrainz token");
input-type: password;
enabled: !ScrobbleState.listenbrainz-busy;
edited(s) => {
ScrobbleState.listenbrainz-token-input = s;
}
accepted(s) => {
ScrobbleActions.listenbrainz-set-token(s);
}
}
}
}
}
if !ScrobbleState.listenbrainz-authed: SettingRow {
label: @tr("Save token");
description: @tr("Validates the token against ListenBrainz.");
SecondaryButton {
label: ScrobbleState.listenbrainz-busy ? @tr("Validating...") : @tr("Connect ListenBrainz");
enabled: !ScrobbleState.listenbrainz-busy;
clicked => {
ScrobbleActions.listenbrainz-set-token(ScrobbleState.listenbrainz-token-input);
}
}
}
// Disconnect (when authed).
if ScrobbleState.listenbrainz-authed: SettingRow {
label: @tr("Disconnect ListenBrainz");
description: @tr("Sign out of ListenBrainz.");
SecondaryButton {
label: @tr("Disconnect");
danger: true;
clicked => { ScrobbleActions.listenbrainz-disconnect(); }
}
}
// Shared status line.
if ScrobbleState.status-text != "": Text {
text: ScrobbleState.status-text;
color: ScrobbleState.status-kind == 3
? #e0564f
: (ScrobbleState.status-kind == 2 ? #3fae6a : Theme.text-muted);
font-size: Typography.legal;
wrap: word-wrap;
}
}
}
@@ -16,6 +16,7 @@ import { PlaybackSettings } from "PlaybackSettings.slint";
import { AppearanceSettings } from "AppearanceSettings.slint";
import { OfflineSettings } from "OfflineSettings.slint";
import { LocalLibrarySettings } from "LocalLibrarySettings.slint";
import { IntegrationsSettings } from "IntegrationsSettings.slint";
import { DeveloperSettings } from "DeveloperSettings.slint";
import { ScrollView } from "std-widgets.slint";
@@ -150,6 +151,14 @@ export component SettingsView inherits Rectangle {
SettingsState.section = 4;
}
}
SubNavItem {
icon: @image-url("../assets/icons/refresh-cw.svg");
label: @tr("Integrations");
active: SettingsState.section == 6;
clicked => {
SettingsState.section = 6;
}
}
SubNavItem {
icon: @image-url("../assets/icons/bug.svg");
label: @tr("Developer");
@@ -225,6 +234,7 @@ export component SettingsView inherits Rectangle {
}
}
if !SettingsState.loading && SettingsState.section == 4: LocalLibrarySettings { }
if !SettingsState.loading && SettingsState.section == 6: IntegrationsSettings { }
if !SettingsState.loading && SettingsState.section == 5: DeveloperSettings { }
}
}
+46
View File
@@ -854,6 +854,52 @@ export global PlexAuthActions {
callback load(); // panel init: seed from store + warm cache
}
// ============================ Scrobblers ==================================
// Settings > Integrations — source-agnostic Last.fm + ListenBrainz scrobbling.
// State mirrors `scrobbler_settings.db`; actions delegate to the Rust
// `scrobble` controller (auth flows + the now-playing/scrobble fire live
// there). ADR-008: no pills.
export global ScrobbleState {
// --- Master section toggles -------------------------------------------
in-out property <bool> enabled: false; // master toggle (default OFF)
in-out property <bool> ui-collapsed: false; // collapse chevron
// --- Last.fm ----------------------------------------------------------
in-out property <bool> lastfm-enabled: false; // per-service scrobble flag
in property <bool> lastfm-authed: false; // session key present
in property <string> lastfm-username: ""; // "Signed in as ..."
in property <string> lastfm-auth-url: ""; // non-empty after connect
in property <bool> lastfm-busy: false; // get_token / get_session in flight
// --- ListenBrainz -----------------------------------------------------
in-out property <bool> listenbrainz-enabled: false;
in property <bool> listenbrainz-authed: false;
in property <string> listenbrainz-username: "";
in-out property <string> listenbrainz-token-input: ""; // token field buffer
in property <bool> listenbrainz-busy: false; // set_token in flight
// --- Shared status line (0 none, 1 info, 2 ok, 3 error) ---------------
in property <string> status-text: "";
in property <int> status-kind: 0;
}
export global ScrobbleActions {
callback load(); // panel init: seed from store
callback enable-toggle(bool);
callback collapse-toggle(bool);
// Last.fm.
callback lastfm-enable-toggle(bool);
callback lastfm-connect(); // get_token + open browser
callback lastfm-open-auth-url(); // re-open authorize URL
callback lastfm-confirm(); // get_session after approve
callback lastfm-disconnect();
// ListenBrainz.
callback listenbrainz-enable-toggle(bool);
callback listenbrainz-set-token(string);
callback listenbrainz-disconnect();
}
// ============================ Tag editor ==================================
// Local-album metadata editor (Settings-independent modal launched from the
// local album detail). Sidecar (default) + opt-in direct file-write. Remote