Skip to content

[Feature] Wire scrobbler into playback lifecycle #126

Description

@lqdev

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

  • playing_now fires on play start (fire-and-forget via tokio::spawn)
  • scrobble fires when dual threshold is met (fire-and-forget)
  • Scrobble only fires once per episode play session (not on every progress tick)
  • Background drain task spawned at startup
  • Graceful flush on app shutdown
  • Playback is never blocked by scrobble network calls
  • cargo test passes
  • cargo clippy -- -D warnings passes

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    audioAudio playback componentblockedBlocked by another issueenhancementNew feature or requestscrobblingScrobbling / listen history tracking

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions