Rocksky — Elixir SDK

Hex.pm Hex Docs Elixir Erlang/OTP NIF License

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.