feat(qbzd): scrobbling auth foundation — Last.fm / ListenBrainz (CONSOLE)

The connect flows, same methodology as `qbzd login`:
- `scrobble login lastfm`: LastFmClient::get_token prints an authorize URL, the
  user approves on last.fm, get_session exchanges it for a session key.
- `scrobble login listenbrainz --token <T>`: ListenBrainzClient::set_token
  validates a pasted user token (like `login --token`).
- `scrobble status` · `scrobble enable|disable <provider>`.

Credentials land in the CANONICAL scrobbler store
(qbz_app::settings::scrobblers::ScrobblerSettingsStore — SQLite at the daemon
data root), the SAME store the desktop uses and the one the settings bundle
export/import already carries, so scrobbler creds travel with `settings export`.
Secrets registered with qbz_log; a running daemon is nudged to reload.

- Cargo.toml: qbz-integrations path dep (already in-tree via qbz-core).
- cli/scrobble.rs: the verbs. main.rs: Scrobble subcommand group.

Verified: cargo test -p qbzd --bin qbzd -> 276 passed, 0 failed.

NOTE: lands CREDENTIALS + auth only. Scrobble-on-play (a daemon task on the
CoreEvent bus: update_now_playing on TrackStarted, scrobble past
scrobble_delay_secs, ListenBrainz submit + offline queue) is the next slice —
it wires the playback driver. The setup-TUI Scrobbler screen is also pending.

cc
This commit is contained in:
vicrodh committed 2026-07-19 19:11:43 -06:00
1 parent 620af9d75d
commit 17de908225
4 files changed
+171

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ qbz-player = { path = "../qbz-player" }
qbz-audio = { path = "../qbz-audio" } # PROTECTED — read-only consumer
qbz-qobuz = { path = "../qbz-qobuz" }
qbz-reco = { path = "../qbz-reco" } # playlist recommendations (ArtistVectorStore); already in-tree via qbz-core
qbz-integrations = { path = "../qbz-integrations" } # Last.fm / ListenBrainz scrobbling; already in-tree via qbz-core
qbz-credentials = { path = "../qbz-credentials" }
qbz-log = { path = "../qbz-log" }
qconnect-core = { path = "../qconnect-core" }
+1
View File
@@ -17,6 +17,7 @@ pub mod queue;
pub mod radio;
pub mod reco;
pub mod resolve;
pub mod scrobble;
pub mod search;
pub mod settings;
pub mod status;
+128
View File
@@ -0,0 +1,128 @@
// crates/qbzd/src/cli/scrobble.rs — the `qbzd scrobble …` verbs (CONSOLE ext).
// Connect Last.fm / ListenBrainz and manage scrobbling, using the SAME
// methodology as `qbzd login`: Last.fm prints an authorize URL and exchanges
// the token after the user approves; ListenBrainz takes a pasted user token
// (like `login --token`). Credentials land in the daemon-root scrobbler store
// (crate::scrobbler); a running daemon is nudged to reload.
//
// These are LOCAL, daemon-down-capable operations (they write the store) — the
// same shape as `login`/`settings set`.
use std::io::{BufRead, Write};
use crate::cli::client::ApiClient;
use crate::paths::ProfileRoots;
use crate::scrobbler::{LastFmCreds, LbCreds, ScrobblerConfig};
/// `qbzd scrobble login lastfm` — the Last.fm web-auth flow (print URL →
/// user approves → exchange for a session key), mirroring `qbzd login`.
pub async fn login_lastfm(host: Option<String>, roots: &ProfileRoots) -> i32 {
let mut client = qbz_integrations::lastfm::LastFmClient::new();
let (token, auth_url) = match client.get_token().await {
Ok(v) => v,
Err(e) => {
eprintln!("error: Last.fm token request failed: {e}");
eprintln!(" → check your connection and retry");
return 1;
}
};
println!("Authorize QBZ on Last.fm, then come back here:");
println!(" {auth_url}");
print!("Press Enter after you've clicked \"Yes, allow access\"… ");
let _ = std::io::stdout().flush();
let mut line = String::new();
let _ = std::io::stdin().lock().read_line(&mut line);
let session = match client.get_session(&token).await {
Ok(s) => s,
Err(e) => {
eprintln!("error: Last.fm authorization not completed: {e}");
eprintln!(" → approve access on the page first, then run this again");
return 1;
}
};
let mut cfg = ScrobblerConfig::load_at(&roots.config);
cfg.lastfm = Some(LastFmCreds { session_key: session.key, username: session.name.clone(), enabled: true });
if let Err(e) = cfg.save_at(&roots.config) {
eprintln!("error: {e}");
return 1;
}
nudge_reload(host).await;
println!("Last.fm connected as {} — scrobbling enabled", session.name);
0
}
/// `qbzd scrobble login listenbrainz --token <TOKEN>` — validate and store a
/// ListenBrainz user token (from listenbrainz.org/settings).
pub async fn login_listenbrainz(host: Option<String>, token: String, roots: &ProfileRoots) -> i32 {
let client = qbz_integrations::listenbrainz::ListenBrainzClient::new();
let info = match client.set_token(&token).await {
Ok(i) => i,
Err(e) => {
eprintln!("error: ListenBrainz token rejected: {e}");
eprintln!(" → get a token at https://listenbrainz.org/settings/");
return 1;
}
};
let mut cfg = ScrobblerConfig::load_at(&roots.config);
cfg.listenbrainz = Some(LbCreds { token, username: info.user_name.clone(), enabled: true });
if let Err(e) = cfg.save_at(&roots.config) {
eprintln!("error: {e}");
return 1;
}
nudge_reload(host).await;
println!("ListenBrainz connected as {} — scrobbling enabled", info.user_name);
0
}
/// `qbzd scrobble status` — per-provider connection + enabled state.
pub fn status(roots: &ProfileRoots) -> i32 {
let cfg = ScrobblerConfig::load_at(&roots.config);
println!("last.fm : {}", provider_line(cfg.lastfm.as_ref().map(|l| (l.enabled, l.username.as_str()))));
println!("listenbrainz : {}", provider_line(cfg.listenbrainz.as_ref().map(|l| (l.enabled, l.username.as_str()))));
0
}
/// `qbzd scrobble disable <lastfm|listenbrainz>` — keep the credentials but
/// stop scrobbling. `enable` re-enables.
pub async fn set_enabled(host: Option<String>, provider: String, enabled: bool, roots: &ProfileRoots) -> i32 {
let mut cfg = ScrobblerConfig::load_at(&roots.config);
let ok = match provider.as_str() {
"lastfm" => cfg.lastfm.as_mut().map(|l| l.enabled = enabled).is_some(),
"listenbrainz" => cfg.listenbrainz.as_mut().map(|l| l.enabled = enabled).is_some(),
other => {
eprintln!("error: unknown provider '{other}'");
eprintln!(" → lastfm | listenbrainz");
return 2;
}
};
if !ok {
eprintln!("error: {provider} is not connected");
eprintln!(" → connect it first: qbzd scrobble login {provider}");
return 1;
}
if let Err(e) = cfg.save_at(&roots.config) {
eprintln!("error: {e}");
return 1;
}
nudge_reload(host).await;
println!("{provider} scrobbling {}", if enabled { "enabled" } else { "disabled" });
0
}
// ============================ internals ============================
fn provider_line(v: Option<(bool, &str)>) -> String {
match v {
Some((true, name)) => format!("on as {name}"),
Some((false, name)) => format!("off (connected as {name})"),
None => "not connected".to_string(),
}
}
/// Best-effort: tell a running daemon to reload (so scrobbler creds take effect
/// live). Silent if the daemon is down — the store write is what matters.
async fn nudge_reload(host: Option<String>) {
let roots = crate::paths::ProfileRoots::resolve(None, None);
let client = ApiClient::new(host, &roots);
let _ = client.post("/api/settings/reload", serde_json::Value::Null).await;
}
+41
View File
@@ -9,6 +9,7 @@ mod lock;
mod login;
mod paths;
mod qconnect;
mod scrobbler;
mod state;
mod tui;
@@ -127,6 +128,8 @@ enum Cmd {
Queue { #[command(subcommand)] cmd: QueueCmd },
Settings { #[command(subcommand)] cmd: SettingsCmd },
Qconnect { #[command(subcommand)] cmd: QconnectCmd },
/// Scrobbling: login (Last.fm / ListenBrainz) · status · enable · disable
Scrobble { #[command(subcommand)] cmd: ScrobbleCmd },
Config { #[command(subcommand)] cmd: ConfigCmd },
Version { #[arg(long)] json: bool },
/// Shell completions (hidden; packaged by T14)
@@ -212,6 +215,26 @@ enum SettingsCmd {
#[derive(Subcommand)]
enum QconnectCmd { Enable, Disable, Name { name: String } }
#[derive(Subcommand)]
enum ScrobbleCmd {
/// Connect a provider
Login { #[command(subcommand)] cmd: ScrobbleLoginCmd },
/// Connection + enabled state
Status,
/// Stop scrobbling to a provider (keeps credentials)
Disable { provider: String },
/// Resume scrobbling to a provider
Enable { provider: String },
}
#[derive(Subcommand)]
enum ScrobbleLoginCmd {
/// Last.fm web auth (prints a URL to approve)
Lastfm,
/// ListenBrainz user token (from listenbrainz.org/settings)
Listenbrainz { #[arg(long)] token: String },
}
// The tokenless default has no rotation verb (02 §3.1.2): `config` is just
// path|show. Rotating the opt-in [server] token = edit qbzd.toml + restart.
#[derive(Subcommand)]
@@ -483,6 +506,24 @@ async fn main() {
QconnectCmd::Name { name } => cli::settings::qconnect_name(&roots, &name),
}
}
Cmd::Scrobble { cmd } => {
let roots = login_roots();
match cmd {
ScrobbleCmd::Login { cmd } => match cmd {
ScrobbleLoginCmd::Lastfm => cli::scrobble::login_lastfm(cli.host, &roots).await,
ScrobbleLoginCmd::Listenbrainz { token } => {
cli::scrobble::login_listenbrainz(cli.host, token, &roots).await
}
},
ScrobbleCmd::Status => cli::scrobble::status(&roots),
ScrobbleCmd::Disable { provider } => {
cli::scrobble::set_enabled(cli.host, provider, false, &roots).await
}
ScrobbleCmd::Enable { provider } => {
cli::scrobble::set_enabled(cli.host, provider, true, &roots).await
}
}
}
Cmd::Config { cmd } => {
let roots = login_roots();
match cmd {