Summary
Hook the Scrobbler trait into the playback lifecycle to submit listen events to the configured scrobble server. This completes the scrobbling feature by connecting the client module to actual audio playback.
Depends on: #125
Blocked by: Audio playback implementation (not yet started)
Related: RFC-002 (docs/rfcs/RFC-002-scrobbling.md)
Problem / Background
The src/scrobbling/ module provides the Scrobbler trait and ListenBrainzClient, but nothing invokes it yet. Once audio playback is implemented, the playback engine needs to fire scrobble events at the right moments.
Proposed Solution
1. Hold scrobbler in app state
Construct the scrobbler during UIApp initialization (or App::run):
let scrobbler: Arc<dyn Scrobbler> = if config.scrobbling.enabled {
match &config.scrobbling.endpoint {
Some(endpoint) => Arc::new(ListenBrainzClient::new(endpoint, &config.scrobbling, &data_dir)),
None => {
log::warn!("Scrobbling enabled but no endpoint configured");
Arc::new(NoopScrobbler)
}
}
} else {
Arc::new(NoopScrobbler)
};
Store as Arc<dyn Scrobbler> field on the app struct.
2. Fire-and-forget dispatch at playback events
Following the existing trigger_async_* pattern (trigger_async_download, etc.):
// On play start — fire playing_now (ephemeral, best-effort)
let scrobbler = self.scrobbler.clone();
let event = ScrobbleEvent::from_episode(&podcast, &episode);
tokio::spawn(async move {
if let Err(e) = scrobbler.playing_now(&event).await {
log::debug!("playing_now scrobble failed: {}", e);
}
});
// When dual threshold met (position >= min_listen_seconds AND percent >= min_listen_percent)
let scrobbler = self.scrobbler.clone();
let event = ScrobbleEvent::from_episode(&podcast, &episode);
tokio::spawn(async move {
if let Err(e) = scrobbler.scrobble(&event).await {
log::warn!("scrobble failed (queued for retry): {}", e);
}
});
3. Dual threshold check
fn should_scrobble(config: &ScrobblingConfig, position_secs: u32, duration_secs: u32) -> bool {
if duration_secs == 0 { return false; }
let percent = (position_secs as f64 / duration_secs as f64 * 100.0) as u8;
position_secs >= config.min_listen_seconds
&& percent >= config.min_listen_percent
}
4. Graceful shutdown
On app exit, attempt to flush pending scrobbles:
// In app shutdown sequence
if let Err(e) = scrobbler.flush_pending().await {
log::warn!("Failed to flush pending scrobbles on shutdown: {}", e);
}
5. Background drain task startup
At app initialization (if scrobbling enabled), spawn the background drain task that periodically retries failed scrobbles.
Files to Modify
| File |
Change |
src/ui/app.rs (or wherever playback state lives) |
Add scrobbler: Arc<dyn Scrobbler> field, fire events at play/threshold/shutdown |
src/app.rs |
Construct scrobbler during app initialization |
Acceptance Criteria
Testing Strategy
Unit Tests
should_scrobble() with various position/duration combinations
- Verify scrobble fires exactly once per play session
Integration Tests
- Mock server + full playback cycle: start → progress → threshold → scrobble submitted
- Verify retry queue drains on reconnect
Implementation Notes
- This issue is BLOCKED on audio playback implementation. Open it now for tracking but do not start work until playback exists.
- The
ScrobbleEvent::from_episode() constructor should map Episode fields to ScrobbleEvent fields (title, guid, duration seconds→ms, position seconds→ms, current unix timestamp).
Summary
Hook the
Scrobblertrait into the playback lifecycle to submit listen events to the configured scrobble server. This completes the scrobbling feature by connecting the client module to actual audio playback.Depends on: #125
Blocked by: Audio playback implementation (not yet started)
Related: RFC-002 (
docs/rfcs/RFC-002-scrobbling.md)Problem / Background
The
src/scrobbling/module provides theScrobblertrait andListenBrainzClient, but nothing invokes it yet. Once audio playback is implemented, the playback engine needs to fire scrobble events at the right moments.Proposed Solution
1. Hold scrobbler in app state
Construct the scrobbler during
UIAppinitialization (orApp::run):Store as
Arc<dyn Scrobbler>field on the app struct.2. Fire-and-forget dispatch at playback events
Following the existing
trigger_async_*pattern (trigger_async_download, etc.):3. Dual threshold check
4. Graceful shutdown
On app exit, attempt to flush pending scrobbles:
5. Background drain task startup
At app initialization (if scrobbling enabled), spawn the background drain task that periodically retries failed scrobbles.
Files to Modify
src/ui/app.rs(or wherever playback state lives)scrobbler: Arc<dyn Scrobbler>field, fire events at play/threshold/shutdownsrc/app.rsAcceptance Criteria
playing_nowfires on play start (fire-and-forget via tokio::spawn)scrobblefires when dual threshold is met (fire-and-forget)cargo testpassescargo clippy -- -D warningspassesTesting Strategy
Unit Tests
should_scrobble()with various position/duration combinationsIntegration Tests
Implementation Notes
ScrobbleEvent::from_episode()constructor should map Episode fields to ScrobbleEvent fields (title, guid, duration seconds→ms, position seconds→ms, current unix timestamp).