Packages

Elixir SDK for Rocksky — native bindings to the shared Rust core: AppView reads, AT Protocol PDS writes (scrobble, like, follow, shout), and identity hashes.

Current section

Files

Jump to
rocksky_ex README.md
Raw

README.md

# Rocksky — Elixir SDK

[![Hex.pm](https://img.shields.io/hexpm/v/rocksky_ex.svg?logo=elixir)](https://hex.pm/packages/rocksky_ex)
[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/rocksky_ex/)
![Elixir](https://img.shields.io/badge/Elixir-1.15%2B-4B275F?logo=elixir&logoColor=white)
![Erlang/OTP](https://img.shields.io/badge/Erlang%2FOTP-27%2B-A90533?logo=erlang&logoColor=white)
![NIF](https://img.shields.io/badge/native-erl__nif-5C4B8A)
![License](https://img.shields.io/badge/license-MIT-blue)

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

```elixir
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

```elixir
# 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}`.

```elixir
{: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`):

```elixir
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}`.

```elixir
{: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

```sh
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.

```elixir
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.