Skip to content

feat: queue failed scrobbles offline and resubmit with original timestamps#445

Closed
firatege wants to merge 4 commits into
Kopuz-org:masterfrom
firatege:feat/offline-scrobble-queue
Closed

feat: queue failed scrobbles offline and resubmit with original timestamps#445
firatege wants to merge 4 commits into
Kopuz-org:masterfrom
firatege:feat/offline-scrobble-queue

Conversation

@firatege

@firatege firatege commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #335. Scrobbles that failed (no network, server hiccup) were logged and lost, so offline listening never reached Last.fm / Libre.fm / ListenBrainz. Both protocols support backdated submissions (Last.fm track.scrobble takes a timestamp, ListenBrainz takes listened_at), so this adds an offline queue that resubmits them with their original listen time.

How it works

New scrobble::queue module:

  • A scrobble that fails with a transient error (no response at all, or a 5xx) is persisted to scrobble_queue.json next to config.json, with its original timestamp and the services it's still owed to (one listen failing on two services stays one entry with two pending services).
  • Permanent errors (4xx, e.g. revoked credentials) are never queued, retrying can't succeed.
  • The queue is drained on startup and whenever a scrobble succeeds again (a success means we're online). During a drain, the first transient failure for a service skips that service for the rest of the run instead of hammering an unreachable host.
  • Capped at 500 entries (oldest dropped first); a corrupt or missing file loads as an empty queue; file access is serialized behind a mutex.
  • Now-playing is deliberately not queued, it's only meaningful live.

Also in this PR

The local-playback path was missing the Libre.fm now-playing/scrobble submission entirely (#378 only added it to the server-stream path), so local plays never scrobbled to Libre.fm. It's wired up now, and the queue covers it.

Tests

Unit tests in scrobble::queue: queue cap and oldest-first dropping, save/load roundtrip, corrupt/missing file loads empty, cross-service merge of the same listen, credential-less drain releases entries.

Verified end to end

08:17:02  Libre.fm scrobble failed: error sending request
08:17:02  queued offline scrobble: Kero Kero Bonito - Bonito Intro service="Libre.fm"
08:18:52  Libre.fm scrobbled: Kero Kero Bonito - Intro Bonito
08:18:52  draining scrobble queue (1 items)
08:18:54  resubmitted queued scrobble: Kero Kero Bonito - Bonito Intro service="Libre.fm"

Queue file emptied afterwards and the resubmitted listen kept its original timestamp.

Summary by CodeRabbit

  • New Features
    • Added a persistent offline scrobble queue with automatic retry for failed submissions across Last.fm, Libre.fm, and MusicBrainz.
  • Bug Fixes
    • Retried scrobbles/listens now preserve the original listen time via explicit timestamps.
    • Offline queued items are retried later: the queue drains after successful submissions and is processed once on startup (non-wasm).
    • Improved local-track scrobbling metadata for MusicBrainz (additional recording ID) and improved Libre.fm “now playing” gating/initialization.

firatege added 2 commits June 21, 2026 22:40
…tamps

Scrobbles that failed (no network, server down) were logged and lost,
so offline listening never reached Last.fm / Libre.fm / ListenBrainz.
Both protocols support backdated submissions, so nothing forces that.

New scrobble::queue module: a scrobble that fails with a transient
error (no response or 5xx) is persisted to scrobble_queue.json with its
original listen timestamp and the services it is still owed to. The
queue is drained on startup and whenever a scrobble succeeds again
(which signals connectivity is back). Permanent errors (4xx, e.g.
revoked credentials) are never queued since retrying cannot succeed.
The queue is capped at 500 entries, oldest dropped first, and a corrupt
or missing file simply loads as an empty queue.

While wiring this into the player controller, the local-playback path
also gained the Libre.fm now-playing/scrobble submission that Kopuz-org#378 only
added to the server-stream path.

Covered by unit tests (cap/oldest-drop, save/load roundtrip, corrupt
file, cross-service merge, credential-less drain) and verified end to
end by blocking the scrobble hosts, playing a track (entry queued),
unblocking, and playing another (queue drained, resubmitted with the
original timestamp).

Closes Kopuz-org#335
Review feedback: a truncated write could corrupt the queue file, which
load() silently treats as empty, dropping the whole backlog — save now
writes to a temp file and renames. And drain only persisted at the end,
so an interrupted run would re-send already delivered scrobbles — it
now checkpoints after every entry.
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ecdb5d4-6360-414f-be30-2613d5c5a191

📥 Commits

Reviewing files that changed from the base of the PR and between b8f1568 and 620e91d.

📒 Files selected for processing (1)
  • crates/scrobble/src/queue.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/scrobble/src/queue.rs

📝 Walkthrough

Walkthrough

Adds an offline scrobble queue (crates/scrobble/src/queue.rs) that persists failed scrobbles to disk and retries them. Refactors Last.fm, Libre.fm, and MusicBrainz scrobble constructors to accept explicit listened_at timestamps. Integrates enqueue-on-transient-failure and drain-on-success logic into the player controller scrobble paths, and schedules a startup drain for previously buffered scrobbles.

Changes

Offline Scrobble Buffering and Retry

Layer / File(s) Summary
Timestamped scrobble constructors and crate wiring
crates/scrobble/Cargo.toml, crates/scrobble/src/lib.rs, crates/scrobble/src/lastfm.rs, crates/scrobble/src/librefm.rs, crates/scrobble/src/musicbrainz.rs
Adds make_scrobble_at to Last.fm and Libre.fm modules, make_listen_at to MusicBrainz, exposes the new queue module under #[cfg(not(target_arch = "wasm32"))], and pulls in directories and reqwest (with json feature) as dependencies.
Queue data model, persistence, and enqueue
crates/scrobble/src/queue.rs (lines 1–160)
Defines Service, QueuedScrobble, ScrobbleQueue, Credentials, default_queue_path, and is_transient; implements forgiving JSON load, atomic temp-rename save, size-capped push, and the enqueue function that merges entries by (timestamp, artist, title) under a global async mutex.
Queue drain, submission logic, and tests
crates/scrobble/src/queue.rs (lines 162–393)
Implements drain (per-entry checkpoint saves, pending-list updates, entry removal on completion), the Outcome enum, submit_one (protocol-specific payload construction, credential gating, transient/permanent error classification), and unit tests for capping, save/load roundtrip, enqueue merging, and drain with absent credentials.
Player controller: timestamped paths with offline buffering
crates/hooks/src/use_player_controller.rs (lines 1001–1766)
Updates both non-wasm streaming and local-track scrobble paths to compute listened_at, use *_at constructors, set scrobble_ok on success, enqueue transient failures, and drain the offline queue after any successful scrobble. Also adds recording_mbid to local-track MusicBrainz metadata and explicitly gates Libre.fm now-playing on the configured session key.
Startup drain of offline queue
crates/hooks/src/use_player_controller.rs (lines 2262–2289)
Schedules a one-time background use_future task at startup (non-wasm only) that assembles credentials from config and calls scrobble::queue::drain to resubmit previously buffered scrobbles.

Sequence Diagram(s)

sequenceDiagram
  participant PlayerController as use_player_controller
  participant Service as Last.fm / Libre.fm / MusicBrainz
  participant Queue as scrobble::queue
  participant Disk as scrobble_queue.json

  Note over PlayerController: On scrobble event
  PlayerController->>PlayerController: compute listened_at, scrobble_ok=false
  PlayerController->>Service: submit *_at scrobble payload
  alt HTTP success
    Service-->>PlayerController: Ok
    PlayerController->>PlayerController: scrobble_ok = true
    PlayerController->>Queue: drain(path, credentials)
    Queue->>Disk: load queued items
    Queue->>Service: resubmit each pending scrobble
    Service-->>Queue: Sent / Transient / Permanent
    Queue->>Disk: checkpoint save after each entry
  else Transient error (network / 5xx)
    Service-->>PlayerController: error
    PlayerController->>Queue: enqueue(service, artist, title, album, listened_at)
    Queue->>Disk: merge + save queue
  end

  Note over PlayerController: On startup (non-wasm)
  PlayerController->>Queue: drain(path, credentials) via use_future
  Queue->>Disk: load queued items
  Queue->>Service: resubmit all pending scrobbles
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Kopuz-org/kopuz#206: Introduced the Last.fm scrobbling integration in use_player_controller.rs and the scrobble::lastfm module that this PR extends with make_scrobble_at and offline retry.
  • Kopuz-org/kopuz#333: Added MusicBrainz additional_info plumbing to listen payloads, which this PR builds on by adding recording_mbid and introducing make_listen_at.

Suggested reviewers

  • temidaradev

Poem

🐇 Hoppity-hop, no net? No fear!
My scrobbles are saved, they will appear.
I queue them up tight in a JSON file snug,
Then drain them all out with a satisfied shug.
Last.fm, Libre.fm — none shall be missed,
Every listen remembered, none left unkissed! 🎵

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing offline scrobble queuing and resubmission with original timestamps, directly addressing issue #335.
Linked Issues check ✅ Passed The PR fulfills issue #335 requirements by implementing persistent offline queue for failed scrobbles with automatic resubmission upon connectivity restoration.
Out of Scope Changes check ✅ Passed All changes are within scope: queue module implementation, _at constructors for timestamped submissions, offline queue drain on startup, and Libre.fm local-playback support for #335.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/hooks/src/use_player_controller.rs`:
- Around line 1702-1707: The code at line 1702 creates a listening event with
MusicBrainz metadata in the map parameter passed to make_listen_at, but the
queuing logic around line 1729 only stores artist, title, album, and timestamp
without preserving the additional_info map containing release/recording/track
MBIDs. When offline retries reconstruct ListenBrainz payloads from the queue,
the MusicBrainz IDs are lost. Modify the queued item structure to also persist
the additional_info map (containing the MBIDs), and ensure that when draining
the queue and reconstructing payloads, this metadata is passed back to
make_listen_at or equivalent payload construction logic.
- Around line 1001-1008: The `listened_at` timestamp capture in the scrobble
handling code is occurring after the `threshold_secs` sleep, which means it
records the scrobble submission time rather than the original listen start time.
Move the `listened_at` calculation block (containing
std::time::SystemTime::now(), duration_since(std::time::UNIX_EPOCH), as_secs(),
and unwrap_or(0)) to immediately after the duration check and before any
threshold sleep operations, then reuse this captured value after the threshold
wait. Apply the same fix to the local-track scrobble task in the similar
timestamp capture block around lines 1599-1606 to ensure consistency across both
submission paths.

In `@crates/scrobble/src/queue.rs`:
- Around line 167-225: The drain function holds QUEUE_LOCK across the entire
operation including the await on submit_one network calls, which blocks the
enqueue function for the full drain duration. Refactor to narrow the lock scope:
acquire the lock only to load the queue with ScrobbleQueue::load, release it
before processing the loop over queue.items where submit_one is awaited, then
re-acquire the lock only when performing checkpoint saves with queue.save. This
allows network submissions to run concurrently with new enqueue operations and
prevents blocking scrobble persistence during drain.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 33b757ae-ae86-4f1c-a1cf-8f90214e57d3

📥 Commits

Reviewing files that changed from the base of the PR and between f61e08f and 08f5de8.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • crates/hooks/src/use_player_controller.rs
  • crates/scrobble/Cargo.toml
  • crates/scrobble/src/lastfm.rs
  • crates/scrobble/src/lib.rs
  • crates/scrobble/src/librefm.rs
  • crates/scrobble/src/musicbrainz.rs
  • crates/scrobble/src/queue.rs

Comment on lines +1001 to +1008
// Offline queue bookkeeping (issue #335): scrobbles
// that fail with a transient error are queued with
// this listen timestamp and resubmitted later; one
// success means we're online, so drain the backlog.
let listened_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Capture listened_at before the threshold wait.

Line 1005 and Line 1603 run after the now-playing calls and threshold_secs sleep, so direct submissions and queued retries are timestamped at the scrobble attempt time rather than the original listen start. Move this capture to immediately after the duration check and reuse it after the threshold.

Proposed adjustment
                                     // track must be longer than 30 seconds
                                     if duration_secs < 30 {
                                         return;
                                     }
+
+                                    let listened_at = std::time::SystemTime::now()
+                                        .duration_since(std::time::UNIX_EPOCH)
+                                        .map(|d| d.as_secs() as i64)
+                                        .unwrap_or(0);

                                     {
                                         let source = scrobble_source.peek().clone();
                                         if let Err(e) =
                                             source.scrobble_now_playing(&scrobble_id).await
@@
-                                        let listened_at = std::time::SystemTime::now()
-                                            .duration_since(std::time::UNIX_EPOCH)
-                                            .map(|d| d.as_secs() as i64)
-                                            .unwrap_or(0);
                                         let queue_path = scrobble::queue::default_queue_path();

Apply the same move in the local-track scrobble task before its now-playing/threshold work.

Also applies to: 1599-1606

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hooks/src/use_player_controller.rs` around lines 1001 - 1008, The
`listened_at` timestamp capture in the scrobble handling code is occurring after
the `threshold_secs` sleep, which means it records the scrobble submission time
rather than the original listen start time. Move the `listened_at` calculation
block (containing std::time::SystemTime::now(),
duration_since(std::time::UNIX_EPOCH), as_secs(), and unwrap_or(0)) to
immediately after the duration check and before any threshold sleep operations,
then reuse this captured value after the threshold wait. Apply the same fix to
the local-track scrobble task in the similar timestamp capture block around
lines 1599-1606 to ensure consistency across both submission paths.

Comment on lines +1702 to +1707
let listen = scrobble::musicbrainz::make_listen_at(
&scrobble_track.artist,
&scrobble_track.title,
Some(&scrobble_track.album),
Some(map.clone()),
listened_at,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve MusicBrainz IDs in queued ListenBrainz retries.

Line 1706 sends Some(map.clone()) for the online ListenBrainz submission, but Line 1729 queues only artist/title/album/timestamp; the queue drain reconstructs ListenBrainz payloads with no additional_info. Offline retries will lose the release/recording/track MBIDs populated above, so please persist and replay that metadata for queued ListenBrainz items.

Also applies to: 1729-1736

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/hooks/src/use_player_controller.rs` around lines 1702 - 1707, The code
at line 1702 creates a listening event with MusicBrainz metadata in the map
parameter passed to make_listen_at, but the queuing logic around line 1729 only
stores artist, title, album, and timestamp without preserving the
additional_info map containing release/recording/track MBIDs. When offline
retries reconstruct ListenBrainz payloads from the queue, the MusicBrainz IDs
are lost. Modify the queued item structure to also persist the additional_info
map (containing the MBIDs), and ensure that when draining the queue and
reconstructing payloads, this metadata is passed back to make_listen_at or
equivalent payload construction logic.

Comment thread crates/scrobble/src/queue.rs

@UMCEKO UMCEKO left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code quality lgtm

@UMCEKO

UMCEKO commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

still need to resolve rabbit's comments tho

@temidaradev

Copy link
Copy Markdown
Member

sup @firatege can you fix the conflicts

@firatege

firatege commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Yes, I can do that, but it will take some time I don't have access to a computer right now.

@temidaradev

Copy link
Copy Markdown
Member

im adding new stuff to scrobbling stuff so you may close this when you access your pc and add offline scrobbling to new build

@temidaradev

Copy link
Copy Markdown
Member

ehhh im gonna take over this im bored .d

@temidaradev

Copy link
Copy Markdown
Member

i took over this, can be closed

@temidaradev temidaradev closed this Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Offline support for scorbblers

3 participants