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