Rocksky — Elixir SDK
Elixir bindings to the shared Rocksky Rust core (rocksky-sdk) via the
:rocksky_erl Rustler NIF: AppView reads, AT Protocol PDS writes (scrobble
fan-out, like, follow, shout) and the identity hashes — the same engine behind
every Rocksky SDK.
Installation
def deps do
[{:rocksky_ex, "~> 0.14.0"}]
end
rocksky_ex 0.14.0 requires rocksky_erl ~> 0.10.0, whose loader fetches the native library from
the GitHub release on first use (checksum-verified). For monorepo dev, build it
locally and point at it: ../erlang/build-core.sh then set
ROCKSKY_ERL_PATH=../erlang.
Quick start
# Reads — unauthenticated. The last arg overrides https://api.rocksky.app.
{:ok, stats} = Rocksky.global_stats()
IO.puts(stats["scrobbles"])
{:ok, top} = Rocksky.top_tracks(10, 0)
for t <- top, do: IO.puts("#{t["artist"]} — #{t["title"]}")
# Writes — log in once (session persisted at the given path).
agent = Rocksky.login("session.json", "alice.bsky.social", "app-password")
{:ok, out} = Rocksky.scrobble(agent, %{
"title" => "Chaser", "artist" => "Calibro 35",
"album" => "Jazzploitation", "albumArtist" => "Calibro 35", "durationMs" => 182_320
})
IO.puts(out["scrobbleUri"])
API
Reads/writes return {:ok, value} | {:error, message} with binary-keyed maps
(the wire shape). Records are maps with camelCase binary keys.
Reads — Rocksky
Named reads: profile(actor, base \\ ""),
scrobbles(actor, limit, offset, base \\ ""),
top_tracks(limit, offset, base \\ ""), global_stats(base \\ ""). The trailing
base overrides the AppView URL.
Universal get — the escape hatch reaches the whole app.rocksky.* read
catalog by NSID: get(nsid, params \\ %{}, base \\ "", token \\ "") → {:ok, data}.
{:ok, albums} = Rocksky.get("app.rocksky.album.getAlbums", %{"limit" => 20})
{:ok, tracks} = Rocksky.get("app.rocksky.album.getAlbumTracks", %{"uri" => uri})
{:ok, follows} = Rocksky.get("app.rocksky.graph.getFollows", %{"actor" => actor})
{:ok, stats} = Rocksky.get("app.rocksky.stats.getStats")
Pass a bearer token (4th arg) for auth-gated queries — sent as
Authorization: Bearer.
Filtering — the catalog and scrobble-feed queries take an RSQL filter,
built with the pipe-friendly Rocksky.Filter (fields are atoms;
and/or/in are reserved in Elixir, hence and_/or_/is_in/is_out):
alias Rocksky.Filter
filter =
Filter.eq(:artist, "Daft Punk")
|> Filter.and_(Filter.gt(:duration, 200_000))
|> Filter.or_(Filter.is_in(:genre, ["house", "electro"]))
{:ok, songs} = Rocksky.catalog_songs(50, 0, nil, filter)
# filter=artist=="Daft Punk";duration=gt=200000,genre=in=(house,electro)
# Scrobble feed: dotted atoms reach the joined track/user/artist.
{:ok, feed} = Rocksky.scrobble_feed(nil, false, 50, 0, Filter.eq(:"track.artist", "Daft Punk"))
catalog_songs/5, catalog_artists/5, catalog_albums/5
(limit, offset, genre, filter, base) and
scrobble_feed/6 (did, following, limit, offset, filter, base) all accept a
Rocksky.Filter or a raw RSQL binary. String values are quoted and escaped
automatically; * wildcards stay bare for case-insensitive matching
(Filter.eq(:artist, "Daft*")).
Typed date-window charts — top_tracks_interval(limit, offset, interval) and
top_artists_interval(limit, offset, interval), where interval is :all |
{:days, n} | {:weeks, n} | {:months, n} | {:years, n} |
{:range, start, end}.
{:ok, top} = Rocksky.top_tracks_interval(5, 0, {:days, 7})
{:ok, top} = Rocksky.top_artists_interval(5, 0, :all)
Match — match_song(title, artist) resolves a bare title + artist into full
canonical metadata.
Writes — Rocksky
login(session_path, identifier, password, appview \\ "", dedup_path \\ "") → an
opaque agent handle. Then scrobble(agent, track) (full metadata; fans out to
artist/album/song/scrobble), like(agent, uri, cid), follow(agent, did),
shout(agent, subject_uri, subject_cid, message), refresh_session(agent).
Match-then-scrobble — scrobble_match(agent, input) resolves canonical
metadata and scrobbles in one call. input is a map with camelCase string keys:
required "title"/"artist", optional "album" (override), "mbId"/"isrc"
(match anchors) and "timestamp" (scrobbled-at Unix seconds, default now) —
e.g. scrobble_match(agent, %{"title" => "Chaser", "artist" => "Calibro 35"}).
The full-metadata scrobble/2 still works when you already have it.
Dedup + realtime — pass a dedup_path to login/5 to enable the local dedup
store, then keep it warm with sync_repo(agent) and
hydrate_from_jetstream(agent).
Identity hashes
Rocksky.song_hash(title, artist, album) — lowercase-hex SHA-256, identical
to the server and every other Rocksky SDK.
Example
ROCKSKY_ERL_PATH=../erlang mix run examples/native_core.exs
License
MIT.
Typed XRPC and raw calls
Rocksky.Api exposes generated endpoint functions with typed Rocksky.Models
structs and specs. Required fields are enforced, and responses are validated
recursively, including nested Discogs data.
alias Rocksky.{Api, Models, Xrpc}
client = Xrpc.new(token: "your-api-key")
params = %Models.SongGetSongParams{uri: "at://did:plc:example/app.rocksky.song/example"}
{:ok, song} = Api.song_get_song(client, params)
IO.inspect(song.mb_id)
{:ok, response} = Xrpc.raw(client, :get, "app.rocksky.song.getSong", %{"uri" => params.uri})
IO.inspect({response.status, response.body})
# POST accepts independent query parameters and a JSON-compatible body:
# Xrpc.raw(client, :post, nsid, params, body)
Typed calls return {:error, {:http, status, body}},
{:error, {:transport, reason}} or {:error, {:decode, reason}} on failure.
Raw calls preserve non-2xx HTTP responses. Use endpoint: and timeout:
(milliseconds) when creating the client to configure transport.
Existing native-core APIs remain available. The typed layer uses HTTP directly
and accepts your bearer token; it does not automatically read native-agent
sessions. Query arrays use repeated URL parameters. Fields the lexicons
explicitly leave open, such as provider payloads, remain JSON values.
Regenerate with bun tools/lexgen/generate.ts --elixir from the repository root.