Skip to content

various: add offline scrobbling support#511

Merged
temidaradev merged 8 commits into
masterfrom
offline-scrobbling
Jul 9, 2026
Merged

various: add offline scrobbling support#511
temidaradev merged 8 commits into
masterfrom
offline-scrobbling

Conversation

@temidaradev

@temidaradev temidaradev commented Jul 8, 2026

Copy link
Copy Markdown
Member

i took the stuff from pr #445 and yeah thats it but i will improve this fixed #335

Sanity Checking

  • I have read and followed the contribution guidelines.
  • My commits follow Kopuz's scoped commit convention and history hygiene
    rules.
  • I have disclosed any AI assistance as required by the AI policy in the
    contribution guidelines, or this pull request did not use AI assistance.
  • I have tested and self-reviewed my changes.

Style and Consistency

  • My changes are consistent with the existing crate boundaries and Dioxus
    style.
  • I ran cargo fmt --all --check or cargo fmt --all as appropriate.
  • I ran cargo clippy --workspace --all-targets -- -D warnings, or
    explained why it could not be run.
  • I kept generated assets, translations, and packaging files in sync when
    this change depends on them.

Testing

  • I ran the smallest relevant verifier for this change.
  • I documented any platform or verifier that I could not run.

Tested on platform(s):

  • x86_64-linux
  • aarch64-linux
  • x86_64-darwin
  • aarch64-darwin
  • Windows
  • Android
  • iOS

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a DB-backed offline scrobble queue, wires enqueue/drain paths into scheduler and startup logic, and updates scrobble constructors to accept explicit timestamps. It also simplifies macOS callback storage and replaces transmute-based pointer casts.

Changes

Offline Scrobble Queue

Layer / File(s) Summary
Timestamped scrobble constructors
crates/scrobble/src/lastfm.rs, crates/scrobble/src/librefm.rs, crates/scrobble/src/lib.rs, crates/scrobble/Cargo.toml
Adds make_scrobble_at, routes existing constructors through it, exports queue and the shared Service type, and adds the DB dependency.
Queue schema and storage surface
crates/db/migrations/20260709000000_scrobble_queue.sql, crates/db/src/lib.rs, crates/db/src/backend/mod.rs, crates/db/src/backend/scrobble_queue.rs
Defines the persisted queue shape and DB APIs for listing, inserting, deleting, and pruning queued scrobbles.
Queue orchestration
crates/scrobble/src/queue.rs
Implements DB-backed enqueue and drain behavior, including transient failure handling, credential construction, service-specific submission, and queue tests.
Scheduler enqueue and drain
crates/hooks/src/scrobble_scheduler.rs
Passes the DB handle into scrobble scheduling, enqueues transient failures for all services, and drains queued scrobbles after successful submissions.
Startup drain in player controller
crates/hooks/src/use_player_controller.rs, crates/kopuz/src/main.rs
Passes the config-loaded signal into the controller, forwards DB handles into scheduling, and runs a one-time startup queue drain from app config.

macOS System Integration Cleanup

Layer / File(s) Summary
Handler aliases and callback flow
crates/player/src/systemint/macos.rs
Introduces type aliases for stored handlers and simplifies callback retrieval logic.
Explicit pointer casts
crates/player/src/systemint/macos.rs
Replaces transmute-based pointer conversions with explicit casts in power assertion and now-playing artwork code.

Possibly related PRs

  • Kopuz-org/kopuz#206: Touches the same Last.fm scrobble code in crates/scrobble/src/lastfm.rs that now routes through make_scrobble_at.
  • Kopuz-org/kopuz#493: Modifies the same MusicBrainz/ListenBrainz scheduling path updated in scrobble_scheduler.rs.

Suggested reviewers: UMCEKO

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The macOS system-integration refactor appears unrelated to offline scrobbling and goes beyond the linked issue scope. Move the macOS refactor to a separate PR unless it is required for offline scrobbling, and keep this PR focused on queueing and draining scrobbles.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: adding offline scrobbling support.
Description check ✅ Passed The description is related to the changes and references prior offline-scrobbling work and issue #335.
Linked Issues check ✅ Passed The PR adds a DB-backed offline queue and drains it later, which satisfies the offline scrobbling requirement.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

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.

@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: 2

🧹 Nitpick comments (4)
crates/hooks/src/scrobble_scheduler.rs (1)

276-293: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting Credentials-from-config into a shared helper.

This Credentials construction is duplicated in use_player_controller.rs (Lines 1029-1046), and the two copies disagree on Libre.fm gating: here it is gated on has_librefm (which includes options.include_librefm), whereas the startup path gates only on a non-empty librefm_session_key. A single helper (e.g. Credentials::from_config(&AppConfig)) would remove the drift and keep drain behavior consistent across both call sites.

The drop(cfg) before drain(...).await correctly avoids holding a signal borrow across the await.

🤖 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/scrobble_scheduler.rs` around lines 276 - 293, Extract the
scrobble queue Credentials construction into a shared helper, since
scrobble_scheduler::drain and the startup path in use_player_controller both
build the same scrobble::queue::Credentials but with different Libre.fm gating.
Add a single source of truth such as Credentials::from_config or a small helper
near scrobble::queue, and update both call sites to use it so lastfm, librefm,
and listenbrainz fields are derived consistently from AppConfig and the current
feature flags.

Source: Coding guidelines

crates/scrobble/src/queue.rs (3)

101-106: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add unit tests for is_transient.

This function is the sole arbiter of whether a failed scrobble is queued for retry or permanently dropped. Misclassification in either direction is a data-loss bug (dropping retryable scrobbles) or a resource leak (infinitely re-queueing permanent failures). The existing test suite covers persistence and merge logic but not this classification.

🧪 Suggested tests
#[test]
fn is_transient_no_status_is_transient() {
    // A connection/timeout error has no HTTP status → transient.
    // Constructing a reqwest::Error without status requires a builder
    // or a specific error kind; use reqwest::Error::builder if available,
    // or test indirectly via a mock that returns a connect error.
    // At minimum, document the expected behavior for:
    // - None (no response) → transient
    // - 500 (server error) → transient
    // - 400 (client error) → not transient
    // - 401 (auth failure) → not transient
}
🤖 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/scrobble/src/queue.rs` around lines 101 - 106, Add unit tests for the
is_transient function in scrobble::queue to cover the retry classification
behavior. Create focused tests around is_transient that verify no HTTP status is
treated as transient, server errors like 500 are transient, and client/auth
errors like 400 and 401 are not transient. Use the is_transient symbol directly
so the tests stay aligned with the retry/drop decision logic.

172-236: 🧹 Nitpick | 🔵 Trivial

At-least-once delivery: crash between submit and checkpoint causes duplicate scrobbles.

drain submits to the service (line 190) and checkpoints to disk afterward (lines 223-233). If the process exits in that window, the next drain re-submits the same item. Most scrobbling services deduplicate by timestamp+artist+track, so this is likely harmless in practice — but worth documenting as a known property of the design.

🤖 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/scrobble/src/queue.rs` around lines 172 - 236, Document the
at-least-once behavior in drain so it’s clear that submit_one can succeed before
the checkpoint write in ScrobbleQueue::drain, and a crash in that window may
cause the same scrobble to be resent on the next run. Add a concise note near
the drain flow or its public docs mentioning the submit-then-checkpoint design
and that duplicate delivery is an expected tradeoff, since most services
deduplicate by timestamp/artist/title.

58-76: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Blocking file I/O in async context.

ScrobbleQueue::load and save use std::fs::read_to_string/write/rename — blocking calls invoked from async enqueue and drain. For a small local config file this is negligible in practice, but it can stall the tokio worker thread on slow filesystems. Consider tokio::fs or spawn_blocking if this path ever moves to a network-backed config dir.

🤖 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/scrobble/src/queue.rs` around lines 58 - 76, ScrobbleQueue::load and
ScrobbleQueue::save perform blocking std::fs I/O that can stall the tokio
runtime when called from async enqueue and drain. Update the queue persistence
path to use non-blocking filesystem APIs such as tokio::fs, or wrap the existing
file operations in spawn_blocking if you want to keep the current
implementation. Keep the changes localized to the ScrobbleQueue load/save
methods and the async callers that invoke them.
🤖 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 1023-1048: The startup scrobble drain in
use_player_controller::use_future is running before App has finished hydrating
config, so it can build empty scrobble::queue::Credentials and clear the offline
queue via scrobble::queue::drain. Gate this effect on the loaded-config state,
or add a dedicated “scrobble creds ready” check before constructing the
credentials and calling drain, so the startup retry only runs once the real
config values are available.

In `@crates/scrobble/src/queue.rs`:
- Around line 277-278: Queued ListenBrainz submits are dropping
scheduler-provided metadata because submit_one() always calls
musicbrainz::make_listen with None for additional_info. Update QueuedScrobble to
store the attached payload (duration and MBIDs) alongside the item, then have
submit_one() pass that saved value through when rebuilding the listen. Make sure
the queue/drain path preserves and reuses the same metadata field that the live
submission path attaches.

---

Nitpick comments:
In `@crates/hooks/src/scrobble_scheduler.rs`:
- Around line 276-293: Extract the scrobble queue Credentials construction into
a shared helper, since scrobble_scheduler::drain and the startup path in
use_player_controller both build the same scrobble::queue::Credentials but with
different Libre.fm gating. Add a single source of truth such as
Credentials::from_config or a small helper near scrobble::queue, and update both
call sites to use it so lastfm, librefm, and listenbrainz fields are derived
consistently from AppConfig and the current feature flags.

In `@crates/scrobble/src/queue.rs`:
- Around line 101-106: Add unit tests for the is_transient function in
scrobble::queue to cover the retry classification behavior. Create focused tests
around is_transient that verify no HTTP status is treated as transient, server
errors like 500 are transient, and client/auth errors like 400 and 401 are not
transient. Use the is_transient symbol directly so the tests stay aligned with
the retry/drop decision logic.
- Around line 172-236: Document the at-least-once behavior in drain so it’s
clear that submit_one can succeed before the checkpoint write in
ScrobbleQueue::drain, and a crash in that window may cause the same scrobble to
be resent on the next run. Add a concise note near the drain flow or its public
docs mentioning the submit-then-checkpoint design and that duplicate delivery is
an expected tradeoff, since most services deduplicate by timestamp/artist/title.
- Around line 58-76: ScrobbleQueue::load and ScrobbleQueue::save perform
blocking std::fs I/O that can stall the tokio runtime when called from async
enqueue and drain. Update the queue persistence path to use non-blocking
filesystem APIs such as tokio::fs, or wrap the existing file operations in
spawn_blocking if you want to keep the current implementation. Keep the changes
localized to the ScrobbleQueue load/save methods and the async callers that
invoke them.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: eecc94d8-7ade-4a07-9818-a8169000fea8

📥 Commits

Reviewing files that changed from the base of the PR and between be54cef and 8a794fc.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • crates/hooks/src/scrobble_scheduler.rs
  • 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/queue.rs

Comment thread crates/hooks/src/use_player_controller.rs
Comment thread crates/scrobble/src/queue.rs Outdated

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

🧹 Nitpick comments (1)
crates/player/src/systemint/macos.rs (1)

191-193: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Casts are sound, but update the now-stale SAFETY comment above.

Swapping transmute for reference-to-pointer as casts is correct here. However, the SAFETY block at Lines 159-164 still explains soundness in terms of "transmute from &NSString to *const c_void", which no longer matches the code. Refresh the wording to describe the as casts / toll-free bridging so the rationale stays accurate.

As per coding guidelines: "Keep comments focused on the non-obvious reason why something exists; do not restate the code."

🤖 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/player/src/systemint/macos.rs` around lines 191 - 193, The SAFETY
comment in the macOS assertion setup is stale and still describes a
transmute-based conversion, while the code now uses reference-to-pointer as
casts for toll-free bridging. Update the comment near the assertion creation
logic in the macos system interface to match the current cast approach and
explain the soundness in terms of objc2_foundation::NSString being bridged to
const c_void. Keep it focused on the non-obvious safety rationale and remove
wording that restates the code.

Source: Coding guidelines

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

Nitpick comments:
In `@crates/player/src/systemint/macos.rs`:
- Around line 191-193: The SAFETY comment in the macOS assertion setup is stale
and still describes a transmute-based conversion, while the code now uses
reference-to-pointer as casts for toll-free bridging. Update the comment near
the assertion creation logic in the macos system interface to match the current
cast approach and explain the soundness in terms of objc2_foundation::NSString
being bridged to const c_void. Keep it focused on the non-obvious safety
rationale and remove wording that restates the code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b911b9e8-10cd-4807-ab99-0869fbe16fd1

📥 Commits

Reviewing files that changed from the base of the PR and between 8a794fc and bf36361.

📒 Files selected for processing (1)
  • crates/player/src/systemint/macos.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.

We should not use json for this

Comment thread crates/scrobble/src/queue.rs Outdated
Comment thread crates/scrobble/src/queue.rs Outdated

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/hooks/src/use_player_controller.rs (1)

1027-1056: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Delay the startup drain until config is populated

config_loaded_ok flips before config.set(...), so this effect can run once against AppConfig::default(). drain() treats missing creds as NoCredentials and checkpoints those services out of the queue, which permanently drops queued scrobbles instead of retrying them. Move the flag set to after config.set(...) or make the effect depend on the actual creds-ready state.

🤖 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 1027 - 1056, The
startup scrobble drain is running before the real config is available, so
use_effect in use_player_controller can build Credentials from
AppConfig::default() and trigger scrobble::queue::drain with missing auth. Move
the config_loaded_ok update until after config.set(...) completes, or gate this
effect on a creds-ready state derived from config, so drain only runs once valid
Last.fm/Libre.fm/ListenBrainz credentials are populated.
🤖 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.

Outside diff comments:
In `@crates/hooks/src/use_player_controller.rs`:
- Around line 1027-1056: The startup scrobble drain is running before the real
config is available, so use_effect in use_player_controller can build
Credentials from AppConfig::default() and trigger scrobble::queue::drain with
missing auth. Move the config_loaded_ok update until after config.set(...)
completes, or gate this effect on a creds-ready state derived from config, so
drain only runs once valid Last.fm/Libre.fm/ListenBrainz credentials are
populated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fd99fea6-8134-44c9-a15f-88cc9b738b2d

📥 Commits

Reviewing files that changed from the base of the PR and between bf5403e and f6927ce.

📒 Files selected for processing (4)
  • crates/hooks/src/scrobble_scheduler.rs
  • crates/hooks/src/use_player_controller.rs
  • crates/kopuz/src/main.rs
  • crates/scrobble/src/queue.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/scrobble/src/queue.rs
  • crates/hooks/src/scrobble_scheduler.rs

@temidaradev temidaradev requested a review from UMCEKO July 9, 2026 13:03

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/hooks/src/scrobble_scheduler.rs (1)

279-293: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Build drain credentials from raw Libre.fm credential presence.
has_librefm is gated by options.include_librefm, so a ScrobbleOptions::LOCAL scrobble can pass None for librefm_session_key into scrobble::queue::drain(). That makes queued Libre.fm rows look delivered via Outcome::NoCredentials, and they get dropped.

🤖 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/scrobble_scheduler.rs` around lines 279 - 293, Build the
scrobble drain Credentials from the actual presence of the Libre.fm session key
instead of the include_librefm flag, so scrobble::queue::drain receives a usable
librefm_session_key whenever one exists. In scrobble_scheduler::scrobble_ok,
update the creds construction around Credentials and the has_librefm logic so
LOCAL scrobbles with a populated Libre.fm key are not treated as NoCredentials
and dropped from the queue.
🤖 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/scrobble/src/queue.rs`:
- Around line 102-127: The Outcome::NoCredentials arm in the match statement on
submit_one() currently sets delivered to true, which silently and permanently
deletes queued scrobbles without distinguishing between temporary credential
absence and permanent removal. To preserve data integrity when credentials are
transiently unavailable, modify the Outcome::NoCredentials case to behave like
Outcome::Transient by adding the service to give_up and setting delivered to
false instead, ensuring rows remain in the queue until credentials return.
Additionally, update the test drain_without_credentials_clears_queue to reflect
this changed behavior.

---

Outside diff comments:
In `@crates/hooks/src/scrobble_scheduler.rs`:
- Around line 279-293: Build the scrobble drain Credentials from the actual
presence of the Libre.fm session key instead of the include_librefm flag, so
scrobble::queue::drain receives a usable librefm_session_key whenever one
exists. In scrobble_scheduler::scrobble_ok, update the creds construction around
Credentials and the has_librefm logic so LOCAL scrobbles with a populated
Libre.fm key are not treated as NoCredentials and dropped from the queue.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f7ed8fe-49b1-4762-9104-5f3370b5f76b

📥 Commits

Reviewing files that changed from the base of the PR and between f6927ce and f8c94f1.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • crates/db/migrations/20260709000000_scrobble_queue.sql
  • crates/db/src/backend/mod.rs
  • crates/db/src/backend/scrobble_queue.rs
  • crates/db/src/lib.rs
  • crates/hooks/src/scrobble_scheduler.rs
  • crates/hooks/src/use_player_controller.rs
  • crates/scrobble/Cargo.toml
  • crates/scrobble/src/lib.rs
  • crates/scrobble/src/queue.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/scrobble/Cargo.toml

Comment thread crates/scrobble/src/queue.rs
Comment thread crates/db/src/backend/scrobble_queue.rs Outdated
Comment thread crates/db/src/backend/scrobble_queue.rs Outdated
Comment thread crates/db/src/backend/scrobble_queue.rs Outdated
Comment thread crates/db/src/backend/scrobble_queue.rs Outdated
Comment thread crates/db/src/backend/scrobble_queue.rs
Comment thread crates/db/src/lib.rs Outdated
@temidaradev temidaradev requested a review from UMCEKO July 9, 2026 15:11

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

just this, rest lgtm

Comment thread crates/db/src/backend/scrobble_queue.rs
@temidaradev temidaradev merged commit 45515c0 into master Jul 9, 2026
11 checks passed
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

2 participants