rocksky 0.3.0

Async Rust SDK for the Rocksky XRPC API.
Documentation

rocksky — Rust SDK

Async Rust SDK for the Rocksky XRPC API.

  • Async-first — built on tokio + reqwest
  • Typed — strongly typed serde models, snake_case API
  • Builder-friendly — every list/paginated endpoint and every multi-field request is a fluent builder (.limit(10).offset(0).send().await)
  • Idiomatic — namespaced accessors: client.actor(), client.scrobble(), …
  • Escape hatchclient.call() / client.procedure() for any XRPC method not yet wrapped
  • Pipe-friendly — every response round-trips through serde_json, so you can stream results straight to stdout or shell pipelines (jq, etc.)
  • Compiles on Rust 1.75+

Install

[dependencies]
rocksky = "0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Pin TLS backend if you don't want rustls (the default):

rocksky = { version = "0.2", default-features = false, features = ["native-tls"] }

Quickstart

use rocksky::Client;

#[tokio::main]
async fn main() -> rocksky::Result<()> {
    let client = Client::new();

    let me = client.actor().get_profile("tsiry-sandratraina.com").await?;
    println!("{}{}",
        me.display_name.as_deref().unwrap_or(""),
        me.did.as_deref().unwrap_or(""));

    let scrobbles = client
        .scrobble()
        .list()
        .did(me.did.clone().unwrap_or_default())
        .limit(10)
        .send()
        .await?;
    for s in scrobbles {
        println!("  {}{}",
            s.artist.as_deref().unwrap_or("?"),
            s.title.as_deref().unwrap_or("?"));
    }
    Ok(())
}

Authenticating

Pass a bearer token at construction time:

let client = rocksky::Client::builder()
    .token("eyJhbGciOi…")
    .build();

…or change it later:

client.set_token(Some("new-token".into())).await;

Self-hosting / custom base URL

Default is https://api.rocksky.app:

let client = rocksky::Client::builder()
    .base_url("http://localhost:8000")
    .token(token)
    .timeout(std::time::Duration::from_secs(10))
    .build();

Builders everywhere

The fluent style maps every optional field to a chainable method, then calls .send() to dispatch:

// List builder
let songs = client.actor().get_songs("did:plc:7vdlgi2bflelz7mmuxoqjfcr")
    .limit(50)
    .offset(0)
    .start_date(chrono::Utc::now() - chrono::Duration::days(30))
    .send().await?;

// Mutation builder (only the required args are positional)
let _ = client.scrobble().create("Hounds of Love", "Kate Bush")
    .album("Hounds of Love")
    .duration(298_000)
    .year(1985)
    .send().await?;

// Charts
let top = client.charts().top_tracks()
    .limit(100)
    .send().await?;

Resources

Namespace Accessor Methods (selected)
app.rocksky.actor client.actor() get_profile, get_profile_me, get_albums, get_artists, get_songs, get_scrobbles, get_loved_songs, get_playlists, get_neighbours, get_compatibility
app.rocksky.album client.album() get, list, get_tracks
app.rocksky.artist client.artist() get, list, get_albums, get_tracks, get_listeners, get_recent_listeners
app.rocksky.song client.song() get, get_by_mbid, get_by_isrc, get_by_spotify_id, list, match_song, get_recent_listeners, create
app.rocksky.scrobble client.scrobble() get, list, create
app.rocksky.charts client.charts() top_tracks, top_artists, scrobbles_chart
app.rocksky.feed client.feed() get, search, stories, recommendations, artist_recommendations, album_recommendations, get_generator, list_generators
app.rocksky.graph client.graph() follow, unfollow, get_followers, get_follows, get_known_followers
app.rocksky.shout client.shout() create, reply, remove, report, for_profile, for_album, for_artist, for_track, replies
app.rocksky.like client.like() like_song, dislike_song, like_shout, dislike_shout
app.rocksky.playlist client.playlist() get, list, create, remove, start, insert_files, insert_directory
app.rocksky.player client.player() currently_playing, queue, play, pause, next, previous, seek, play_file, play_directory, add_items_to_queue
app.rocksky.spotify client.spotify() currently_playing, play, pause, next, previous, seek
app.rocksky.apikey client.apikey() list, create, update, remove
app.rocksky.stats client.stats() get, wrapped
app.rocksky.mirror client.mirror() list_sources, put_source
app.rocksky.dropbox client.dropbox() list_files, metadata, temporary_link, download_file
app.rocksky.googledrive client.googledrive() list_files, get_file, download_file

For methods that aren't typed yet, drop down to the generic escape hatch:

let raw: serde_json::Value =
    client.call("app.rocksky.feed.describeFeedGenerator").await?;

Errors

All fallible methods return Result<T, rocksky::Error>. The Error enum carries the full server response when the API returns a non-2xx:

use rocksky::Error;

match client.song().get("at://does-not-exist").await {
    Ok(song) => println!("{song:?}"),
    Err(e) if e.is_not_found() => eprintln!("not found"),
    Err(Error::Api { status, error, message, .. }) => {
        eprintln!("api error {status}: {error:?} / {message:?}");
    }
    Err(Error::MissingToken { method }) => {
        eprintln!("auth required for {method}");
    }
    Err(e) => return Err(e.into()),
}

Convenience predicates: is_unauthorized(), is_forbidden(), is_not_found(), is_rate_limited(), is_client_error(), is_server_error().

Pipe-friendly CLI use

The included search example streams hits as one JSON object per line — drop-in for jq:

cargo run --example search -- "kate bush" \
    | jq -c '{type: .type, title: (.title // .name)}'

Examples

Runnable examples live in examples/:

  • examples/quickstart.rs — fetch a profile and recent scrobbles
  • examples/scrobble.rs — submit a scrobble (needs ROCKSKY_TOKEN)
  • examples/wrapped.rs — print a user's year-in-review JSON
  • examples/search.rs — search + line-delimited JSON output
  • examples/follow_feed.rs — page through the follow-graph feed

Run with:

cargo run --example quickstart -- tsiry-sandratraina.com

Types

Public model types are derived from the Rocksky lexicons and live in rocksky::generated. The hand-written rocksky::models module re-exports them under their historical SDK names and extends Profile / ApiKey with fields the lexicon does not yet model. Regenerate with bun run lexgen:types at the repo root.

Testing your own code against the SDK

Provide your own reqwest::Client and point the SDK at a mock server. The SDK's own test suite uses wiremock:

use wiremock::{Mock, ResponseTemplate};
use wiremock::matchers::{method, path};

#[tokio::test]
async fn it_works() {
    let server = wiremock::MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/xrpc/app.rocksky.feed.search"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(serde_json::json!({"hits": []})))
        .mount(&server).await;

    let client = rocksky::Client::builder().base_url(server.uri()).build();
    let results = client.feed().search("kate bush").await.unwrap();
    assert!(results.hits.is_empty());
}

Development

cargo build
cargo test
cargo doc --open

License

MIT © Tsiry Sandratraina.