# rockbox_ex
[](https://hex.pm/packages/rockbox_ex)
Idiomatic Elixir SDK for [Rockbox Zig](https://github.com/tsirysndr/rockbox-zig) — a fully typed
GraphQL client for `rockboxd` with real-time WebSocket subscriptions, a
plugin behaviour, and a builder DSL for smart playlists.
- **Pipe-friendly** — every API function takes the client as its first arg.
- **Builder-friendly** — smart-playlist rules and partial settings updates
compose with `|>`.
- **Tagged tuples or bangs** — `name/N → {:ok, value} | {:error, exception}`,
with a matching `name!/N` that raises.
- **Real-time events as messages** — `Rockbox.subscribe(:track_changed)` and
receive `{:rockbox, :track_changed, %Rockbox.Track{}}`.
- **Plugins** — implement `Rockbox.Plugin` and install with
`Rockbox.use_plugin/2`.
---
## Table of contents
- [Installation](#installation)
- [Quick start](#quick-start)
- [Configuration](#configuration)
- [API reference](#api-reference)
- [Playback](#playback)
- [Library](#library)
- [Queue (live playlist)](#queue-live-playlist)
- [Saved playlists](#saved-playlists)
- [Smart playlists](#smart-playlists)
- [Sound](#sound)
- [Settings](#settings)
- [System](#system)
- [Browse (filesystem)](#browse-filesystem)
- [Devices](#devices)
- [Bluetooth](#bluetooth)
- [Real-time events](#real-time-events)
- [Plugins](#plugins)
- [Error handling](#error-handling)
- [Raw GraphQL queries](#raw-graphql-queries)
---
## Installation
```elixir
def deps do
[
{:rockbox_ex, "~> 0.1"}
]
end
```
`rockboxd` must be running and reachable. By default the SDK connects to
`http://localhost:6062/graphql`. Start rockboxd with:
```sh
rockbox start
```
---
## Quick start
```elixir
client = Rockbox.new()
# Optional: open the WebSocket so subscribers receive events
{:ok, _pid} = Rockbox.connect(client)
# What's playing right now?
case Rockbox.Playback.current_track(client) do
{:ok, %Rockbox.Track{} = t} -> IO.puts("▶ #{t.title} — #{t.artist}")
{:ok, nil} -> IO.puts("Nothing is playing.")
end
# Search the library
{:ok, results} = Rockbox.Library.search(client, "dark side")
album = List.first(results.albums)
# Play it shuffled
:ok = Rockbox.Playback.play_album(client, album.id, shuffle: true)
# React to track changes
:ok = Rockbox.subscribe(:track_changed)
receive do
{:rockbox, :track_changed, track} ->
IO.puts("Now: #{track.title}")
end
# Tear down when done
Rockbox.disconnect(client)
```
---
## Configuration
```elixir
# Defaults: localhost:6062
client = Rockbox.new()
# Custom host and port
client = Rockbox.new(host: "192.168.1.42", port: 6062)
# Fully custom URLs (useful behind a reverse proxy)
client = Rockbox.new(
http_url: "https://music.home/graphql",
ws_url: "wss://music.home/graphql"
)
```
| Option | Type | Default | Description |
|-------------|------------------------|----------------------------------|-----------------------------------------------------|
| `:host` | `String.t()` | `"localhost"` | Hostname or IP of rockboxd |
| `:port` | `non_neg_integer()` | `6062` | GraphQL HTTP/WS port |
| `:http_url` | `String.t()` | `http://{host}:{port}/graphql` | Override the full HTTP URL |
| `:ws_url` | `String.t()` | `ws://{host}:{port}/graphql` | Override the full WebSocket URL |
| `:headers` | `[{String.t(), String.t()}]` | `[]` | Extra HTTP request headers |
| `:timeout` | `non_neg_integer()` | `15_000` | HTTP request timeout (ms) |
---
## API reference
Every function comes in two flavors:
- `name/N → {:ok, value} | {:error, exception}` — for `with`/`case` pipelines.
- `name!/N → value` — raises `Rockbox.Error` (or a subclass) on failure.
### Playback
```elixir
# Status — returns an atom: :stopped | :playing | :paused
{:ok, :playing} = Rockbox.Playback.status(client)
# Toggle
case Rockbox.Playback.status!(client) do
:playing -> Rockbox.Playback.pause(client)
_ -> Rockbox.Playback.resume(client)
end
# Transport
:ok = Rockbox.Playback.next(client)
:ok = Rockbox.Playback.previous(client)
:ok = Rockbox.Playback.stop(client)
# Seek to absolute position (ms)
:ok = Rockbox.Playback.seek(client, 90_000)
# Current / next track — returns nil when stopped
{:ok, %Rockbox.Track{title: t}} = Rockbox.Playback.current_track(client)
{:ok, _next} = Rockbox.Playback.next_track(client)
# Play helpers — single-call shortcuts
:ok = Rockbox.Playback.play_track(client, "/Music/foo.mp3")
:ok = Rockbox.Playback.play_album(client, "album-id", shuffle: true)
:ok = Rockbox.Playback.play_artist(client, "artist-id", shuffle: true)
:ok = Rockbox.Playback.play_playlist(client, "playlist-id")
:ok = Rockbox.Playback.play_directory(client, "/Music/Jazz", recurse: true, shuffle: true)
:ok = Rockbox.Playback.play_liked_tracks(client, shuffle: true)
:ok = Rockbox.Playback.play_all_tracks(client, shuffle: true)
```
`Rockbox.Track` exposes a couple of helpers:
```elixir
Rockbox.Track.format_length(track) # "4:32"
Rockbox.Track.format_elapsed(track) # "1:14"
Rockbox.Track.progress(track) # 0.27 (0.0–1.0)
```
### Library
```elixir
# Albums
{:ok, albums} = Rockbox.Library.albums(client)
{:ok, album} = Rockbox.Library.album(client, "album-id") # full track list
{:ok, liked} = Rockbox.Library.liked_albums(client)
:ok = Rockbox.Library.like_album(client, "album-id")
:ok = Rockbox.Library.unlike_album(client, "album-id")
# Artists
{:ok, artists} = Rockbox.Library.artists(client)
{:ok, artist} = Rockbox.Library.artist(client, "artist-id")
# Tracks
{:ok, tracks} = Rockbox.Library.tracks(client)
{:ok, track} = Rockbox.Library.track(client, "track-id")
{:ok, liked} = Rockbox.Library.liked_tracks(client)
:ok = Rockbox.Library.like_track(client, "track-id")
:ok = Rockbox.Library.unlike_track(client, "track-id")
# Search across artists, albums, tracks, liked
{:ok, results} = Rockbox.Library.search(client, "radiohead")
results.artists # [%Rockbox.Artist{}, ...]
results.albums # [%Rockbox.Album{}, ...]
results.tracks # [%Rockbox.Track{}, ...]
results.liked_tracks
results.liked_albums
# Trigger a full library rescan
:ok = Rockbox.Library.scan(client)
```
### Queue (live playlist)
The *queue* is the live playback list — what plays right now. For persistent
named collections see [Saved playlists](#saved-playlists).
```elixir
{:ok, queue} = Rockbox.Queue.current(client)
queue.amount # total tracks
queue.index # 0-based position of the currently playing track
queue.tracks # [%Rockbox.Track{}, ...]
Rockbox.Playlist.current_track(queue) # convenience helper
# Insertion: position is :next | :after_current | :last | :first
:ok = Rockbox.Queue.insert_tracks(client, ["/Music/a.mp3", "/Music/b.mp3"], :next)
:ok = Rockbox.Queue.insert_directory(client, "/Music/Ambient", :last)
:ok = Rockbox.Queue.insert_album(client, "album-id", :next)
# Other ops
:ok = Rockbox.Queue.remove_track(client, 2)
:ok = Rockbox.Queue.clear(client)
:ok = Rockbox.Queue.shuffle(client)
:ok = Rockbox.Queue.create(client, "Evening Mix", ["/a.mp3", "/b.mp3"])
:ok = Rockbox.Queue.resume(client)
# Pipe-friendly chaining with bang variants
client
|> tap(&Rockbox.Queue.clear!/1)
|> tap(&Rockbox.Queue.insert_tracks!(&1, ["/Music/a.mp3"], :last))
|> Rockbox.Queue.shuffle!()
```
### Saved playlists
```elixir
{:ok, lists} = Rockbox.SavedPlaylists.list(client)
{:ok, lists} = Rockbox.SavedPlaylists.list(client, "folder-id")
{:ok, pl} = Rockbox.SavedPlaylists.get(client, "playlist-id")
{:ok, ids} = Rockbox.SavedPlaylists.track_ids(client, "playlist-id")
# Create
{:ok, pl} =
Rockbox.SavedPlaylists.create(client,
name: "Late Night Jazz",
description: "Quiet music for working",
folder_id: "folder-id", # optional
track_ids: ["t1", "t2", "t3"] # optional
)
# Update / add / remove
:ok = Rockbox.SavedPlaylists.update(client, pl.id, name: "Late Night Jazz (v2)")
:ok = Rockbox.SavedPlaylists.add_tracks(client, pl.id, ["t4", "t5"])
:ok = Rockbox.SavedPlaylists.remove_track(client, pl.id, "t1")
# Play / delete
:ok = Rockbox.SavedPlaylists.play(client, pl.id)
:ok = Rockbox.SavedPlaylists.delete(client, pl.id)
# Folders
{:ok, folders} = Rockbox.SavedPlaylists.folders(client)
{:ok, folder} = Rockbox.SavedPlaylists.create_folder(client, "Work")
:ok = Rockbox.SavedPlaylists.delete_folder(client, folder.id)
```
### Smart playlists
Use the `Rockbox.SmartPlaylist.Rules` builder — pipe-friendly, type-safe.
```elixir
alias Rockbox.SmartPlaylist.Rules
rules =
Rules.all_of()
|> Rules.where(:play_count, :gte, 10)
|> Rules.where(:last_played, :within, "30d")
|> Rules.sort(:play_count, :desc)
|> Rules.limit(50)
|> Rules.to_json()
{:ok, sp} =
Rockbox.SmartPlaylists.create(client,
name: "Most played (last 30d)",
description: "Top 50 most-played tracks from the last month",
rules: rules
)
{:ok, ids} = Rockbox.SmartPlaylists.track_ids(client, sp.id)
:ok = Rockbox.SmartPlaylists.play(client, sp.id)
:ok = Rockbox.SmartPlaylists.delete(client, sp.id)
# OR groups
or_rules =
Rules.any_of()
|> Rules.where(:title, :contains, "Live")
|> Rules.where(:title, :contains, "Acoustic")
# Mixed AND/OR via where_group/2
mixed =
Rules.all_of()
|> Rules.where(:play_count, :gt, 0)
|> Rules.where_group(or_rules)
|> Rules.to_json()
```
#### Listening stats
```elixir
{:ok, stats} = Rockbox.SmartPlaylists.track_stats(client, "track-id")
# Record events manually (e.g. from a scrobbler plugin)
:ok = Rockbox.SmartPlaylists.record_played(client, "track-id")
:ok = Rockbox.SmartPlaylists.record_skipped(client, "track-id")
```
### Sound
Volume is adjusted in firmware-defined steps. The number of steps per dB
varies by hardware target — always inspect `volume/1` for the range.
```elixir
{:ok, %Rockbox.Volume{volume: v, min: lo, max: hi}} = Rockbox.Sound.volume(client)
{:ok, new_value} = Rockbox.Sound.adjust(client, 3) # +3 steps
{:ok, _} = Rockbox.Sound.up(client) # +1
{:ok, _} = Rockbox.Sound.down(client) # -1
```
### Settings
`update/2` accepts any subset of fields — only the ones you pass are written.
```elixir
{:ok, settings} = Rockbox.Settings.get(client)
# Toggle shuffle + repeat
:ok = Rockbox.Settings.update(client, shuffle: true, repeat_mode: 1)
# Equalizer
:ok =
Rockbox.Settings.update(client,
eq_enabled: true,
eq_precut: -3,
eq_band_settings: [
%{cutoff: 60, q: 7, gain: 3},
%{cutoff: 200, q: 7, gain: 0},
%{cutoff: 4000, q: 7, gain: -2}
]
)
# Compressor
:ok =
Rockbox.Settings.update(client,
compressor_settings: %{
threshold: -24, makeup_gain: 3, ratio: 2,
knee: 0, release_time: 100, attack_time: 5
}
)
# Replaygain
:ok =
Rockbox.Settings.update(client,
replaygain_settings: %{noclip: true, type: 1, preamp: 0}
)
```
### System
```elixir
{:ok, version} = Rockbox.System.version(client)
{:ok, status} = Rockbox.System.status(client)
status.runtime # seconds since boot
status.topruntime # peak runtime
status.resume_index # last queued position
```
### Browse (filesystem)
```elixir
{:ok, entries} = Rockbox.Browse.entries(client) # music_dir root
{:ok, entries} = Rockbox.Browse.entries(client, "/Music/Pink Floyd")
for e <- entries do
icon = if Rockbox.Entry.directory?(e), do: "📁", else: "🎵"
IO.puts("#{icon} #{e.name}")
end
{:ok, dirs} = Rockbox.Browse.directories(client, "/Music")
{:ok, files} = Rockbox.Browse.files(client, "/Music/Pink Floyd/The Wall")
```
### Devices
```elixir
{:ok, devices} = Rockbox.Devices.list(client)
{:ok, device} = Rockbox.Devices.get(client, "device-id")
# Connect — switches the active PCM output sink to this device
:ok = Rockbox.Devices.connect(client, "chromecast-id")
:ok = Rockbox.Devices.disconnect(client, "chromecast-id")
```
### Bluetooth
Linux only — backed by BlueZ. Calls return a `Rockbox.GraphQLError` on
non-Linux hosts.
```elixir
{:ok, devices} = Rockbox.Bluetooth.devices(client)
{:ok, found} = Rockbox.Bluetooth.scan(client, 10) # 10 second scan
:ok = Rockbox.Bluetooth.connect(client, "AA:BB:CC:DD:EE:FF")
:ok = Rockbox.Bluetooth.disconnect(client, "AA:BB:CC:DD:EE:FF")
```
---
## Real-time events
Open the WebSocket once with `Rockbox.connect/1`. The connection is supervised
and auto-reconnects with exponential backoff (capped at 30 s). Subscribers
receive plain Erlang messages, so they integrate with `receive` blocks and
`GenServer.handle_info/2`.
```elixir
client = Rockbox.new()
{:ok, _pid} = Rockbox.connect(client)
:ok = Rockbox.subscribe(:track_changed)
:ok = Rockbox.subscribe([:status_changed, :playlist_changed]) # multiple
:ok = Rockbox.subscribe(:all) # catch-all
receive do
{:rockbox, :track_changed, %Rockbox.Track{} = track} ->
IO.puts("▶ #{track.title} — #{track.artist}")
{:rockbox, :status_changed, status} ->
IO.puts("status → #{status}") # :stopped | :playing | :paused
{:rockbox, :playlist_changed, %Rockbox.Playlist{} = queue} ->
IO.puts("queue is now #{queue.amount} tracks")
end
Rockbox.unsubscribe(:track_changed)
Rockbox.disconnect(client)
```
### Event map
| Event | Payload |
|----------------------|-----------------------------------------------|
| `:track_changed` | `%Rockbox.Track{}` |
| `:status_changed` | `:stopped | :playing | :paused` |
| `:playlist_changed` | `%Rockbox.Playlist{}` |
| `:ws_open` | `nil` |
| `:ws_close` | `nil` |
| `:ws_error` | `Exception.t()` |
Subscribers are auto-removed when their process exits — no manual cleanup
needed.
### Inside a GenServer
```elixir
defmodule MyApp.NowPlaying do
use GenServer
def start_link(client), do: GenServer.start_link(__MODULE__, client, name: __MODULE__)
@impl true
def init(client) do
Rockbox.connect(client)
Rockbox.subscribe([:track_changed, :status_changed])
{:ok, %{client: client, track: nil, status: :stopped}}
end
@impl true
def handle_info({:rockbox, :track_changed, track}, state),
do: {:noreply, %{state | track: track}}
def handle_info({:rockbox, :status_changed, status}, state),
do: {:noreply, %{state | status: status}}
end
```
---
## Plugins
Plugins are the recommended way to bolt on cross-cutting features — scrobbling,
desktop notifications, analytics, sleep timers — without forking the SDK.
### Writing a plugin
```elixir
defmodule MyApp.LastFmScrobbler do
@behaviour Rockbox.Plugin
@impl true
def name, do: "lastfm-scrobbler"
@impl true
def version, do: "1.0.0"
@impl true
def description, do: "Scrobble played tracks to Last.fm"
@impl true
def install(ctx) do
{:ok, pid} = MyApp.LastFmScrobbler.Worker.start_link(ctx.client)
{:ok, %{worker: pid}}
end
@impl true
def uninstall(%{worker: pid}) do
if Process.alive?(pid), do: GenServer.stop(pid)
:ok
end
end
defmodule MyApp.LastFmScrobbler.Worker do
use GenServer
def start_link(client), do: GenServer.start_link(__MODULE__, client)
@impl true
def init(client) do
Rockbox.Events.subscribe(:track_changed)
{:ok, %{client: client, current: nil, started_at: 0}}
end
@impl true
def handle_info({:rockbox, :track_changed, track}, state) do
now = System.monotonic_time(:millisecond)
# Submit the previous track if it played for more than 30 s
if state.current && now - state.started_at > 30_000 do
submit_scrobble(state.current)
end
{:noreply, %{state | current: track, started_at: now}}
end
defp submit_scrobble(_track), do: :ok # talk to the Last.fm API here
end
```
### Installing
```elixir
client = Rockbox.new()
{:ok, _} = Rockbox.connect(client)
:ok = Rockbox.use_plugin(client, MyApp.LastFmScrobbler)
# Inspect what's installed
for entry <- Rockbox.installed_plugins() do
IO.puts("#{entry.module.name()} v#{entry.module.version()}")
end
:ok = Rockbox.unuse_plugin("lastfm-scrobbler") # by name
:ok = Rockbox.unuse_plugin(MyApp.LastFmScrobbler) # or by module
```
The `install/1` callback receives `%{client: client}`. Return `{:ok, state}`;
the state is passed back to `uninstall/1` so resources can be cleaned up.
---
## Error handling
```elixir
case Rockbox.Playback.play(client) do
:ok ->
:ok
{:error, %Rockbox.NetworkError{} = e} ->
Logger.error("rockboxd unreachable: #{Exception.message(e)}")
{:error, %Rockbox.GraphQLError{errors: errors}} ->
for %{message: msg} <- errors, do: Logger.error("graphql: #{msg}")
{:error, %Rockbox.Error{} = e} ->
Logger.error("rockbox: #{Exception.message(e)}")
end
# …or use the bang variant inside a try/rescue
try do
Rockbox.Playback.play!(client)
rescue
e in Rockbox.NetworkError -> Logger.error("offline: #{e.message}")
e in Rockbox.GraphQLError -> Logger.error("server: #{e.message}")
end
```
| Exception | When raised |
|---------------------------|----------------------------------------------------------|
| `Rockbox.NetworkError` | HTTP request fails or returns a non-2xx status |
| `Rockbox.GraphQLError` | Server returns `{ "errors": [...] }` in the response body |
| `Rockbox.Error` | Base exception — rescue this to catch any SDK failure |
---
## Raw GraphQL queries
For operations not yet covered by a dedicated function, drop down to
`Rockbox.query/3`. Variables can be a map or keyword list — snake_case keys
are converted to camelCase before being sent.
```elixir
{:ok, %{"rockboxVersion" => v}} =
Rockbox.query(client, "query { rockboxVersion }")
{:ok, %{"album" => album}} =
Rockbox.query(
client,
"query Album($id: String!) { album(id: $id) { id title artist year } }",
id: "abc-123"
)
# Mutation
:ok = Rockbox.query(client, "mutation Seek($t: Int!) { fastForwardRewind(newTime: $t) }", t: 120_000) |> elem(0) == :ok
```
The GraphiQL explorer is available at `http://localhost:6062/graphiql` while
rockboxd is running.
---
## Module map
| Domain | Module |
|-----------------------|---------------------------------|
| Client constructor | `Rockbox`, `Rockbox.Client` |
| Transport controls | `Rockbox.Playback` |
| Library / search | `Rockbox.Library` |
| Live queue | `Rockbox.Queue` |
| Saved playlists | `Rockbox.SavedPlaylists` |
| Smart playlists | `Rockbox.SmartPlaylists` |
| Smart-playlist rules | `Rockbox.SmartPlaylist.Rules` |
| Volume | `Rockbox.Sound` |
| Settings | `Rockbox.Settings` |
| System info | `Rockbox.System` |
| Filesystem browser | `Rockbox.Browse` |
| Output devices | `Rockbox.Devices` |
| Bluetooth | `Rockbox.Bluetooth` |
| Real-time events | `Rockbox.Events` |
| Plugin behaviour | `Rockbox.Plugin`, `Rockbox.Plugins` |
| Errors | `Rockbox.Error`, `Rockbox.NetworkError`, `Rockbox.GraphQLError` |
---
## Development
```sh
mix deps.get
mix test
mix docs # generates HTML docs in doc/
```
Examples live in `examples/`. Start `rockboxd`, then:
```sh
mix run examples/01_basic_playback.exs
mix run --no-halt examples/02_now_playing.exs
```
---
## License
MIT License. See [LICENSE](./LICENSE) for details.