feat(qbzd): scrobble-on-play engine — Last.fm / ListenBrainz (CONSOLE)

The daemon now scrobbles what it plays. A CoreEvent-bus subscriber
(scrobble_engine, spawned in run() next to queue-persist):
- on TrackStarted -> "now playing" to each active provider (LastFm
  update_now_playing / ListenBrainz submit_playing_now);
- scrobbles ONCE when the position crosses the threshold from
  qbz_app::scrobble_timing::scrobble_delay_secs (Last.fm's half-or-4-min rule)
  -> LastFm scrobble / ListenBrainz submit_listen, best-effort + logged;
- re-reads the canonical ScrobblerSettingsStore on each track, so `qbzd scrobble
  login/enable/disable` take effect on the next track with no reload signal;
- holds no Arc<AppRuntime>; aborted on shutdown alongside queue-persist.

Album is dropped when empty / "Unknown Album". Pure helpers (due, album_opt)
are unit-tested. The ListenBrainz offline queue (ListenBrainzCache) is a
follow-up — this slice submits best-effort.

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

cc
This commit is contained in:
vicrodh committed 2026-07-19 19:22:50 -06:00
1 parent 17de908225
commit 1c5c44b904
3 files changed
+207 -1

No files matched your search

+10
View File
@@ -112,6 +112,13 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
// clone, so it is aborted+joined ahead of `drop(booted)` (#521 ordering).
let queue_persist = spawn_queue_persist(booted.runtime.clone(), booted.bus.subscribe());
// 10c. Scrobble-on-play (CONSOLE): a CoreEvent-bus subscriber that sends
// "now playing" on TrackStarted and scrobbles once past the Last.fm
// threshold, to whichever of Last.fm / ListenBrainz is connected +
// enabled in the scrobbler store. Holds NO Arc<AppRuntime>, so it sits
// outside the #521/§8.2 ordering — aborted for a clean shutdown below.
let scrobbler = crate::scrobble_engine::spawn(roots.clone(), booted.bus.subscribe());
// 11. HTTP serve (02 §3) on the already-bound socket. `ApiState` carries a
// second read-only audio-store connection (WAL) for the status audio
// block, the tokio handle for the async queue read, and the opt-in
@@ -187,6 +194,9 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
// `Arc<AppRuntime>` clone alive past `drop(booted)` (#521 ordering).
queue_persist.abort();
let _ = queue_persist.await;
// Stop the scrobble-on-play subscriber (holds no Arc<AppRuntime>; order-free).
scrobbler.abort();
let _ = scrobbler.await;
// Final full session save (queue + position) now that playback is quiesced.
playback_driver::save_session_now(booted.runtime.as_ref()).await;
// The background auth-retry task also holds an Arc<AppRuntime> clone — abort