···11+MIT License
22+33+Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app>
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+691
sdk/clojure/README.md
···11+# rockbox-clj
22+33+Idiomatic Clojure SDK for [Rockbox](https://www.rockbox.org) — a thin,
44+zero-dependency-heavy wrapper around rockboxd's GraphQL API with real-time
55+WebSocket subscriptions and a tiny plugin system.
66+77+* **Pipe-friendly.** Every function takes the client as its first argument.
88+ Action functions return the client so they compose with `->`.
99+* **Builder-friendly.** `with-host`, `with-port`, `with-timeout`,
1010+ `with-headers`, `with-http-url`, `with-ws-url` — all pure, all chainable.
1111+* **Clojure-friendly.** Plain maps with kebab-case keys both in and out;
1212+ enums exposed as keywords; events surface as callbacks _or_ `core.async`
1313+ channels; plugins are plain maps you `assoc` into shape.
1414+* **Light dependencies.** Only `org.clojure/data.json` and `core.async` —
1515+ HTTP and WebSockets ride on JDK 11+'s built-in `java.net.http`.
1616+1717+---
1818+1919+## Table of contents
2020+2121+- [Installation](#installation)
2222+- [Quick start](#quick-start)
2323+- [Configuration](#configuration)
2424+- [API reference](#api-reference)
2525+ - [Playback](#playback)
2626+ - [Library](#library)
2727+ - [Playlist (queue)](#playlist-queue)
2828+ - [Saved playlists](#saved-playlists)
2929+ - [Smart playlists](#smart-playlists)
3030+ - [Sound](#sound)
3131+ - [Settings](#settings)
3232+ - [System](#system)
3333+ - [Browse (filesystem)](#browse-filesystem)
3434+ - [Devices](#devices)
3535+ - [Bluetooth](#bluetooth)
3636+- [Real-time events](#real-time-events)
3737+- [Plugin system](#plugin-system)
3838+- [Error handling](#error-handling)
3939+- [Raw GraphQL queries](#raw-graphql-queries)
4040+- [Types reference](#types-reference)
4141+4242+---
4343+4444+## Installation
4545+4646+`deps.edn`:
4747+4848+```clojure
4949+{:deps {com.rockbox/rockbox-clj {:git/url "https://github.com/tsirysndr/rockbox-zig"
5050+ :git/sha "..."
5151+ :deps/root "sdk/clojure"}}}
5252+```
5353+5454+Or pin via local path while developing:
5555+5656+```clojure
5757+{:deps {com.rockbox/rockbox-clj {:local/root "/path/to/rockbox-zig/sdk/clojure"}}}
5858+```
5959+6060+`rockboxd` must be running and reachable. By default the SDK connects to
6161+`http://localhost:6062/graphql`. Start it with:
6262+6363+```sh
6464+./zig/zig-out/bin/rockboxd
6565+```
6666+6767+---
6868+6969+## Quick start
7070+7171+```clojure
7272+(require '[rockbox.core :as rb]
7373+ '[rockbox.playback :as pb]
7474+ '[rockbox.library :as lib])
7575+7676+(def client (rb/client))
7777+7878+;; Optional: open the WebSocket so subscribers start receiving events
7979+(rb/connect client)
8080+8181+;; What's playing right now?
8282+(when-let [t (pb/current-track client)]
8383+ (println "Now playing:" (:title t) "—" (:artist t)))
8484+8585+;; Search the library
8686+(let [{:keys [albums tracks]} (lib/search client "dark side")]
8787+ (println (count albums) "albums," (count tracks) "tracks"))
8888+8989+;; Play an album, shuffled — in one piped chain
9090+(-> client
9191+ (pb/play-album "album-id" {:shuffle true}))
9292+9393+;; React to track changes
9494+(rb/on client :track-changed
9595+ (fn [t] (println "▶" (:title t) "by" (:artist t))))
9696+9797+;; Tear down when done
9898+(rb/disconnect client)
9999+```
100100+101101+---
102102+103103+## Configuration
104104+105105+```clojure
106106+(require '[rockbox.core :as rb])
107107+108108+;; Defaults: localhost:6062
109109+(def c (rb/client))
110110+111111+;; Custom host and port
112112+(def c (rb/client {:host "192.168.1.42" :port 6062}))
113113+114114+;; Fully custom URLs (e.g. behind a reverse proxy)
115115+(def c (rb/client {:http-url "https://music.home/graphql"
116116+ :ws-url "wss://music.home/graphql"}))
117117+118118+;; Builder style — every with-* fn returns a new client value
119119+(def c (-> (rb/client)
120120+ (rb/with-host "music.home")
121121+ (rb/with-port 6062)
122122+ (rb/with-timeout 30000)
123123+ (rb/with-headers {:x-trace-id "req-123"})))
124124+```
125125+126126+| Option | Default | Description |
127127+|---------------|-----------------------------------|-------------------------------------|
128128+| `:host` | `"localhost"` | rockboxd hostname / IP |
129129+| `:port` | `6062` | GraphQL HTTP/WS port |
130130+| `:http-url` | `http://{host}:{port}/graphql` | Override the full HTTP URL |
131131+| `:ws-url` | `ws://{host}:{port}/graphql` | Override the full WS URL |
132132+| `:timeout-ms` | `15000` | Per-request timeout |
133133+| `:headers` | `{}` | Extra HTTP headers map |
134134+| `:http-client`| (auto) | Reuse a `java.net.http.HttpClient` |
135135+136136+---
137137+138138+## API reference
139139+140140+> Convention: **action functions return the client** so chains compose with
141141+> `->`. **Read functions return data** as plain Clojure maps with kebab-case
142142+> keys.
143143+144144+### Playback
145145+146146+```clojure
147147+(require '[rockbox.playback :as pb]
148148+ '[rockbox.types :as t])
149149+150150+;; Status
151151+(pb/status client) ;=> :playing | :paused | :stopped
152152+(pb/raw-status client) ;=> 0 | 1 | 3 (raw firmware enum)
153153+154154+;; Current / next track
155155+(pb/current-track client) ;=> {:title "..." :artist "..." :elapsed 12345 ...} or nil
156156+(pb/next-track client)
157157+(pb/file-position client)
158158+159159+;; Transport — pipe-friendly
160160+(-> client
161161+ (pb/pause)
162162+ (pb/seek 90000) ; jump to 1:30 (ms)
163163+ (pb/resume))
164164+165165+(pb/play client)
166166+(pb/play client {:elapsed 0 :offset 0})
167167+(pb/next client)
168168+(pb/previous client)
169169+(pb/stop client)
170170+(pb/flush-and-reload client)
171171+172172+;; Single-call play helpers
173173+(pb/play-track client "/Music/Pink Floyd/Wish You Were Here.mp3")
174174+(pb/play-album client "album-id" {:shuffle true})
175175+(pb/play-album client "album-id" {:position 3})
176176+(pb/play-artist client "artist-id" {:shuffle true})
177177+(pb/play-playlist client "playlist-id" {:shuffle true})
178178+(pb/play-directory client "/Music/Jazz" {:recurse true :shuffle true})
179179+(pb/play-liked-tracks client {:shuffle true})
180180+(pb/play-all-tracks client {:shuffle true})
181181+```
182182+183183+---
184184+185185+### Library
186186+187187+```clojure
188188+(require '[rockbox.library :as lib])
189189+190190+;; Albums
191191+(lib/albums client) ;=> vector of album maps with shallow track stubs
192192+(lib/album client "album-id") ;=> album with full track list, or nil
193193+(lib/liked-albums client)
194194+(lib/like-album client "album-id")
195195+(lib/unlike-album client "album-id")
196196+197197+;; Artists
198198+(lib/artists client)
199199+(lib/artist client "artist-id")
200200+201201+;; Tracks
202202+(lib/tracks client)
203203+(lib/track client "track-id")
204204+(lib/liked-tracks client)
205205+(lib/like-track client "track-id")
206206+(lib/unlike-track client "track-id")
207207+208208+;; Search — returns {:artists :albums :tracks :liked-tracks :liked-albums}
209209+(let [{:keys [albums tracks]} (lib/search client "radiohead")]
210210+ (println (count albums) "albums," (count tracks) "tracks"))
211211+212212+;; Trigger a full library scan
213213+(lib/scan client)
214214+```
215215+216216+---
217217+218218+### Playlist (queue)
219219+220220+The *playlist* namespace manages the live playback queue. For persistent
221221+named collections use [Saved playlists](#saved-playlists).
222222+223223+```clojure
224224+(require '[rockbox.playlist :as q]
225225+ '[rockbox.types :as t])
226226+227227+;; Inspect
228228+(q/current client) ;=> {:tracks [...] :amount n :index i ...}
229229+(q/amount client)
230230+231231+;; Queue management — every mutation returns the client
232232+(-> client
233233+ (q/insert-tracks ["/Music/a.mp3" "/Music/b.mp3"] :next)
234234+ (q/insert-album "album-id" :last)
235235+ (q/shuffle))
236236+237237+(q/insert-directory client "/Music/Ambient" :last)
238238+(q/remove-track client 2) ; remove queue index 2
239239+(q/clear client)
240240+(q/create client "Evening Mix" ["/Music/a.mp3" "/Music/b.mp3"])
241241+(q/start client {:start-index 0})
242242+(q/resume client)
243243+```
244244+245245+| `insert-position` keyword | Effect |
246246+|---------------------------|----------------------------------------|
247247+| `:next` | After the currently playing track |
248248+| `:after-current` | After the last manually inserted track |
249249+| `:last` | At the end of the queue |
250250+| `:first` | Replace the entire queue |
251251+252252+(You can also pass the underlying integer if you prefer.)
253253+254254+---
255255+256256+### Saved playlists
257257+258258+```clojure
259259+(require '[rockbox.saved-playlists :as sp])
260260+261261+(sp/list client) ; all
262262+(sp/list client "folder-id") ; in a folder
263263+(sp/get client "playlist-id")
264264+(sp/track-ids client "playlist-id")
265265+266266+(sp/create client {:name "Late Night Jazz"
267267+ :description "Quiet music for working"
268268+ :folder-id "folder-id"
269269+ :track-ids ["t1" "t2" "t3"]})
270270+271271+(sp/update client "playlist-id" {:name "Late Night Jazz (updated)"})
272272+273273+(sp/add-tracks client "playlist-id" ["t4" "t5"])
274274+(sp/remove-track client "playlist-id" "t1")
275275+(sp/play client "playlist-id")
276276+(sp/delete client "playlist-id")
277277+278278+;; Folders
279279+(sp/folders client)
280280+(sp/create-folder client "Work")
281281+(sp/delete-folder client "folder-id")
282282+```
283283+284284+---
285285+286286+### Smart playlists
287287+288288+Smart playlists evaluate a rule set dynamically. The SDK accepts the
289289+`:rules` value as either a JSON string or any Clojure data structure (it
290290+will JSON-encode for you).
291291+292292+```clojure
293293+(require '[rockbox.smart-playlists :as smart])
294294+295295+(smart/list client)
296296+(smart/get client "smart-id")
297297+(smart/track-ids client "smart-id") ; resolve to matching track ids
298298+299299+;; Create — rules as plain Clojure data
300300+(smart/create client
301301+ {:name "Recently played"
302302+ :rules {:operator "AND"
303303+ :rules [{:field "play_count" :op "gt" :value 0}
304304+ {:field "last_played" :op "within" :value "30d"}]}})
305305+306306+;; Or as a pre-baked JSON string
307307+(smart/create client {:name "Top 50" :rules "{\"sort\":{...}}"})
308308+309309+(smart/update client "smart-id" {:name "Recently played (60d)"
310310+ :rules {...}})
311311+(smart/play client "smart-id")
312312+(smart/delete client "smart-id")
313313+314314+;; Listening stats — feeds smart-playlist rules and scrobblers
315315+(smart/track-stats client "track-id") ;=> {:play-count n :skip-count n :last-played t}
316316+(smart/record-played client "track-id")
317317+(smart/record-skipped client "track-id")
318318+```
319319+320320+---
321321+322322+### Sound
323323+324324+Volume is measured in firmware-defined steps (not absolute dB). The number
325325+of steps per dB varies by hardware target.
326326+327327+```clojure
328328+(require '[rockbox.sound :as snd])
329329+330330+(snd/volume client) ;=> {:volume v :min m :max M}
331331+(snd/adjust-volume client +3) ; 3 steps up; returns the new raw volume
332332+(snd/volume-up client) ; +1
333333+(snd/volume-down client) ; -1
334334+```
335335+336336+---
337337+338338+### Settings
339339+340340+```clojure
341341+(require '[rockbox.settings :as settings])
342342+343343+(def s (settings/get client))
344344+(println :music-dir (:music-dir s)
345345+ :volume (:volume s)
346346+ :eq-enabled (:eq-enabled s)
347347+ :repeat-mode (:repeat-mode s))
348348+349349+;; Partial update — only the keys you pass are written
350350+(settings/save client
351351+ {:shuffle true
352352+ :repeat-mode 1}) ; or use rockbox.types/repeat-mode
353353+354354+;; Enable a 5-band EQ
355355+(settings/save client
356356+ {:eq-enabled true
357357+ :eq-precut -3
358358+ :eq-band-settings [{:cutoff 60 :q 7 :gain 3}
359359+ {:cutoff 200 :q 7 :gain 0}
360360+ {:cutoff 800 :q 7 :gain 0}
361361+ {:cutoff 4000 :q 7 :gain -2}
362362+ {:cutoff 12000 :q 7 :gain 1}]})
363363+364364+;; Compressor + ReplayGain
365365+(settings/save client
366366+ {:compressor-settings {:threshold -24 :makeup-gain 3
367367+ :ratio 2 :knee 0
368368+ :attack-time 5 :release-time 100}
369369+ :replaygain-settings {:noclip true :type 1 :preamp 0}})
370370+```
371371+372372+---
373373+374374+### System
375375+376376+```clojure
377377+(require '[rockbox.system :as sys])
378378+379379+(sys/version client) ;=> "1.0.0"
380380+(sys/status client) ;=> {:runtime n :topruntime n :resume-index i ...}
381381+```
382382+383383+---
384384+385385+### Browse (filesystem)
386386+387387+Walk the configured `music_dir`.
388388+389389+```clojure
390390+(require '[rockbox.browse :as br]
391391+ '[rockbox.types :as t])
392392+393393+(br/entries client) ; root of music_dir
394394+(br/entries client "/Music/Pink Floyd")
395395+(br/directories client "/Music")
396396+(br/files client "/Music/Pink Floyd/The Wall")
397397+398398+;; Or filter manually
399399+(filter t/directory? (br/entries client))
400400+```
401401+402402+---
403403+404404+### Devices
405405+406406+Output sinks discovered via mDNS — Chromecast, AirPlay, etc.
407407+408408+```clojure
409409+(require '[rockbox.devices :as dev])
410410+411411+(dev/list client)
412412+(dev/get client "device-id")
413413+(dev/connect client "chromecast-id") ; switches the active PCM sink
414414+(dev/disconnect client "chromecast-id") ; reverts to built-in
415415+```
416416+417417+---
418418+419419+### Bluetooth
420420+421421+Linux-only (BlueZ via D-Bus).
422422+423423+```clojure
424424+(require '[rockbox.bluetooth :as bt])
425425+426426+(bt/devices client)
427427+(bt/scan client) ; default timeout
428428+(bt/scan client 30) ; 30 s
429429+(bt/connect client "AA:BB:CC:DD:EE:FF")
430430+(bt/disconnect client "AA:BB:CC:DD:EE:FF")
431431+```
432432+433433+---
434434+435435+## Real-time events
436436+437437+Call `(rb/connect client)` to open the WebSocket. The connection is lazy
438438+(only created on first call), auto-reconnects with exponential backoff up
439439+to 30 s, and re-subscribes after every reconnect.
440440+441441+```clojure
442442+(require '[rockbox.core :as rb]
443443+ '[rockbox.types :as t])
444444+445445+(rb/connect client)
446446+447447+;; ── Callback API ────────────────────────────────────────────────────────────
448448+(-> client
449449+ (rb/on :track-changed
450450+ (fn [tr] (println "▶" (:title tr) "—" (:artist tr))))
451451+ (rb/on :status-changed
452452+ (fn [raw] (println "status:" (t/playback-status->keyword raw))))
453453+ (rb/on :playlist-changed
454454+ (fn [pl] (println "queue updated:" (:amount pl) "tracks")))
455455+ (rb/on :ws-error
456456+ (fn [e] (println "WS error:" (.getMessage ^Throwable e)))))
457457+458458+;; One-shot listener
459459+(rb/once client :track-changed (fn [tr] (println "First track:" (:title tr))))
460460+461461+;; Remove a listener
462462+(let [h (fn [tr] (println (:title tr)))]
463463+ (rb/on client :track-changed h)
464464+ ;; …later
465465+ (rb/off client :track-changed h))
466466+467467+;; ── core.async API ──────────────────────────────────────────────────────────
468468+(require '[clojure.core.async :as a]
469469+ '[rockbox.events :as events])
470470+471471+(let [ch (events/channel client :track-changed)]
472472+ (a/go-loop []
473473+ (when-let [tr (a/<! ch)]
474474+ (println "▶" (:title tr))
475475+ (recur)))
476476+ ;; …later
477477+ (events/close-channel! client ch))
478478+479479+;; Shut everything down
480480+(rb/disconnect client)
481481+```
482482+483483+### Event map
484484+485485+| Event | Payload | Description |
486486+|---------------------|-------------|--------------------------------------|
487487+| `:track-changed` | track map | Currently playing track changed |
488488+| `:status-changed` | int | Playback status (0=stopped, 1=playing, 3=paused) |
489489+| `:playlist-changed` | playlist | Active queue was modified |
490490+| `:ws-open` | `nil` | WebSocket connection established |
491491+| `:ws-close` | `nil` | WebSocket connection closed |
492492+| `:ws-error` | Throwable | WebSocket / subscription error |
493493+494494+---
495495+496496+## Plugin system
497497+498498+A plugin is a plain map with `:name`, `:install`, and (optionally) `:version`,
499499+`:description`, and `:uninstall`. Compose them with `assoc` / closures.
500500+501501+```clojure
502502+(defn lastfm-scrobbler [{:keys [api-key secret]}]
503503+ (let [state (atom {:current nil :started-at 0})]
504504+ {:name "lastfm-scrobbler"
505505+ :version "1.0.0"
506506+ :description "Scrobble plays > 30 s old to Last.fm"
507507+ :install
508508+ (fn [{:keys [client query events]}]
509509+ ;; `events` is a map of helpers already partially-applied to `client`
510510+ ((:on events) :track-changed
511511+ (fn [tr]
512512+ (let [{:keys [current started-at]} @state]
513513+ (when (and current (> (- (System/currentTimeMillis) started-at) 30000))
514514+ (submit-to-lastfm api-key secret current))
515515+ (reset! state {:current tr :started-at (System/currentTimeMillis)})))))
516516+ :uninstall (fn [] (reset! state {}))}))
517517+518518+(rb/use-plugin client (lastfm-scrobbler {:api-key "..." :secret "..."}))
519519+(rb/installed-plugins client) ;=> [{:name "lastfm-scrobbler" ...}]
520520+(rb/unuse-plugin client "lastfm-scrobbler")
521521+```
522522+523523+The `install` fn receives a context map:
524524+525525+```clojure
526526+{:client client ; the client value
527527+ :query (fn ([gql] ...) ([gql vars] ...))
528528+ :events {:on (partial events/on client)
529529+ :once (partial events/once client)
530530+ :off (partial events/off client)
531531+ :off-all (partial events/off-all client)
532532+ :channel (partial events/channel client)
533533+ :close-channel (partial events/close-channel! client)}}
534534+```
535535+536536+### Plugin with custom queries
537537+538538+```clojure
539539+(def lyrics-plugin
540540+ {:name "lyrics"
541541+ :version "0.1.0"
542542+ :install (fn [{:keys [query events]}]
543543+ ((:on events) :track-changed
544544+ (fn [tr]
545545+ (when (:id tr)
546546+ (let [data (query "query T($id: String!) { track(id: $id) { title artist } }"
547547+ {:id (:id tr)})]
548548+ (fetch-and-display-lyrics (:track data)))))))})
549549+```
550550+551551+### Sleep timer plugin (closes over local state)
552552+553553+```clojure
554554+(defn sleep-timer [minutes]
555555+ (let [t (atom nil)]
556556+ {:name "sleep-timer"
557557+ :version "1.0.0"
558558+ :description (str "Stop playback after " minutes " minutes")
559559+ :install
560560+ (fn [{:keys [query events]}]
561561+ (reset! t (future
562562+ (Thread/sleep (* minutes 60 1000))
563563+ (query "mutation { hardStop }")
564564+ (println "Sleep timer fired — playback stopped.")))
565565+ ((:on events) :status-changed
566566+ (fn [s] (when (zero? s) (some-> @t future-cancel)))))
567567+ :uninstall (fn [] (some-> @t future-cancel))}))
568568+569569+(rb/use-plugin client (sleep-timer 30))
570570+```
571571+572572+---
573573+574574+## Error handling
575575+576576+All errors are `clojure.lang.ExceptionInfo` instances carrying a `:type` key
577577+in their ex-data. One `catch ExceptionInfo` covers everything:
578578+579579+```clojure
580580+(require '[rockbox.errors :as err])
581581+582582+(try
583583+ (pb/play client)
584584+ (catch clojure.lang.ExceptionInfo e
585585+ (case (:type (ex-data e))
586586+ :rockbox/network (println "rockboxd is offline:" (.getMessage e))
587587+ :rockbox/graphql (doseq [g (:errors (ex-data e))]
588588+ (println "GraphQL:" (:message g) (:path g)))
589589+ :rockbox/config (println "Bad input:" (.getMessage e))
590590+ (throw e))))
591591+592592+;; Predicates
593593+(err/network-error? e)
594594+(err/graphql-error? e)
595595+```
596596+597597+| `:type` | When thrown |
598598+|--------------------|-----------------------------------------------------------------|
599599+| `:rockbox/network` | Cannot reach rockboxd, or HTTP returned a non-2xx status |
600600+| `:rockbox/graphql` | Server returned `{errors: [...]}` in the response body |
601601+| `:rockbox/config` | Client constructed with bad config or required input missing |
602602+603603+---
604604+605605+## Raw GraphQL queries
606606+607607+For operations not yet covered by the SDK, use `rb/query`. The GraphiQL
608608+explorer is available at `http://localhost:6062/graphiql` while rockboxd
609609+is running.
610610+611611+```clojure
612612+;; Simple query
613613+(rb/query client "query { rockboxVersion }")
614614+;=> {:rockbox-version "1.0.0"}
615615+616616+;; With variables — kebab-case is auto-converted to camelCase
617617+(rb/query client
618618+ "query Album($id: String!) {
619619+ album(id: $id) { id title artist year }
620620+ }"
621621+ {:id "abc-123"})
622622+623623+;; Mutation
624624+(rb/query client
625625+ "mutation Seek($t: Int!) { fastForwardRewind(newTime: $t) }"
626626+ {:t 120000})
627627+```
628628+629629+---
630630+631631+## Types reference
632632+633633+Enum constants and helpers live in `rockbox.types`:
634634+635635+```clojure
636636+(require '[rockbox.types :as t])
637637+638638+t/playback-status ;=> {:stopped 0, :playing 1, :paused 3}
639639+t/playback-status->keyword ;=> {0 :stopped, 1 :playing, 3 :paused}
640640+t/playing ;=> 1
641641+t/repeat-mode ;=> {:off 0, :all 1, :one 2, :shuffle 3, :ab-repeat 4}
642642+t/channel-config ;=> {:stereo 0, :stereo-narrow 1, ...}
643643+t/replaygain-type ;=> {:track 0, :album 1, :shuffle 2}
644644+t/insert-position ;=> {:next 0, :after-current 1, :last 2, :first 3}
645645+646646+(t/->insert-position :next) ; coerce keyword or int -> int
647647+(t/directory? entry) ; tests entry's :attr bitmask
648648+(t/file? entry)
649649+(t/format-ms 75000) ;=> "1:15"
650650+```
651651+652652+### Selected response shapes
653653+654654+`Track` (kebab-case keys):
655655+656656+```clojure
657657+{:id "..." :title "..." :artist "..." :album "..."
658658+ :genre "..." :album-artist "..." :composer "..."
659659+ :tracknum 1 :discnum 1 :year 1973
660660+ :bitrate 320 :frequency 44100
661661+ :length 12345 ; ms
662662+ :elapsed 6789 ; ms
663663+ :filesize 4567890 :path "/Music/..."
664664+ :album-id "..." :artist-id "..." :album-art "..."}
665665+```
666666+667667+`Playlist`:
668668+669669+```clojure
670670+{:amount 12 :index 3 :max-playlist-size 32000
671671+ :first-index 0 :last-insert-pos -1
672672+ :seed 0 :last-shuffled-start 0
673673+ :tracks [...]}
674674+```
675675+676676+`Device`:
677677+678678+```clojure
679679+{:id "..." :name "..." :host "..." :ip "..." :port 8009
680680+ :service "..." :app "..." :base-url "..."
681681+ :is-connected false
682682+ :is-cast-device true
683683+ :is-source-device false
684684+ :is-current-device false}
685685+```
686686+687687+---
688688+689689+## License
690690+691691+MIT License. See [LICENSE](./LICENSE) for details.
···11+# Examples
22+33+Each file is a runnable Clojure script. They share `example_client.clj`, which
44+builds a `RockboxClient` honouring `ROCKBOX_HOST` / `ROCKBOX_PORT` env vars.
55+66+```sh
77+# From sdk/clojure
88+clj -M:examples -m ex01-basic-playback
99+clj -M:examples -m ex03-library-search "pink floyd"
1010+ROCKBOX_HOST=192.168.1.42 clj -M:examples -m ex02-now-playing
1111+```
1212+1313+| File | What it shows |
1414+|-----------------------------------|-------------------------------------------------------|
1515+| `ex01_basic_playback.clj` | Pause / seek / resume in one threading-macro chain |
1616+| `ex02_now_playing.clj` | Pretty-print the currently playing track |
1717+| `ex03_library_search.clj` | Search → play first matching album shuffled |
1818+| `ex04_queue_management.clj` | Inspect and modify the live queue |
1919+| `ex05_realtime_events.clj` | WebSocket events with the callback API |
2020+| `ex06_core_async_events.clj` | Same events, consumed via `core.async` channels |
2121+| `ex07_volume_eq.clj` | Adjust volume + write a 5-band EQ preset |
2222+| `ex08_browse_filesystem.clj` | Walk `music_dir` (directories vs files) |
2323+| `ex09_plugin_scrobbler.clj` | Toy "scrobbler" plugin via `use-plugin` / event hook |
2424+| `ex10_smart_playlist.clj` | Create a smart playlist from a Clojure data rule-set |
2525+2626+`rockboxd` must be running locally (or specify `ROCKBOX_HOST`) before the
2727+examples can connect.
+22
sdk/clojure/examples/ex01_basic_playback.clj
···11+(ns ex01-basic-playback
22+ "Pause, seek to 1:30, resume — using the threading macro for a pipe-friendly
33+ call chain. Action functions return the client so they compose with `->`."
44+ (:require [example-client :as client]
55+ [rockbox.core :as rb]
66+ [rockbox.playback :as pb]
77+ [rockbox.types :as t]))
88+99+(defn -main [& _]
1010+ (let [c (client/make-client)]
1111+ (println "Status:" (pb/status c))
1212+ (when-let [track (pb/current-track c)]
1313+ (printf "Now playing: %s — %s (%s / %s)%n"
1414+ (:title track) (:artist track)
1515+ (t/format-ms (:elapsed track)) (t/format-ms (:length track))))
1616+1717+ (-> c
1818+ (pb/pause)
1919+ (pb/seek 90000) ; jump to 1:30
2020+ (pb/resume))
2121+2222+ (println "Status after pipe:" (pb/status c))))
+22
sdk/clojure/examples/ex02_now_playing.clj
···11+(ns ex02-now-playing
22+ "Fetch the currently playing track and pretty-print its details."
33+ (:require [example-client :as client]
44+ [rockbox.playback :as pb]
55+ [rockbox.types :as t]))
66+77+(defn -main [& _]
88+ (let [c (client/make-client)
99+ track (pb/current-track c)]
1010+ (if track
1111+ (do
1212+ (println "──────── Now playing ────────")
1313+ (printf " Title : %s%n" (:title track))
1414+ (printf " Artist : %s%n" (:artist track))
1515+ (printf " Album : %s (%d)%n" (:album track) (or (:year track) 0))
1616+ (printf " Position : %s / %s%n"
1717+ (t/format-ms (:elapsed track))
1818+ (t/format-ms (:length track)))
1919+ (printf " Bitrate : %d kbps @ %d Hz%n"
2020+ (or (:bitrate track) 0) (or (:frequency track) 0))
2121+ (printf " Path : %s%n" (:path track)))
2222+ (println "Nothing is playing."))))
+24
sdk/clojure/examples/ex03_library_search.clj
···11+(ns ex03-library-search
22+ "Search the library and play the first matching album, shuffled."
33+ (:require [example-client :as client]
44+ [rockbox.library :as lib]
55+ [rockbox.playback :as pb]))
66+77+(defn -main [& args]
88+ (let [term (or (first args) "radiohead")
99+ c (client/make-client)
1010+ {:keys [artists albums tracks]} (lib/search c term)]
1111+1212+ (printf "Searching for %s%n%n" (pr-str term))
1313+ (printf "Artists (%d):%n" (count artists))
1414+ (doseq [a (take 5 artists)] (printf " • %s%n" (:name a)))
1515+1616+ (printf "%nAlbums (%d):%n" (count albums))
1717+ (doseq [a (take 5 albums)] (printf " • %s — %s (%d)%n" (:title a) (:artist a) (or (:year a) 0)))
1818+1919+ (printf "%nTracks (%d):%n" (count tracks))
2020+ (doseq [t (take 5 tracks)] (printf " • %s — %s%n" (:title t) (:artist t)))
2121+2222+ (when-let [first-album (first albums)]
2323+ (printf "%n▶ Playing %s (shuffled)…%n" (:title first-album))
2424+ (pb/play-album c (:id first-album) {:shuffle true}))))
···11+# Used by "mix format"
22+[
33+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
44+]
+24
sdk/elixir/.gitignore
···11+# The directory Mix will write compiled artifacts to.
22+/_build/
33+44+# If you run "mix test --cover", coverage assets end up here.
55+/cover/
66+77+# The directory Mix downloads your dependencies sources to.
88+/deps/
99+1010+# Where third-party dependencies like ExDoc output generated docs.
1111+/doc/
1212+1313+# Temporary files, for example, from tests.
1414+/tmp/
1515+1616+# If the VM crashes, it generates a dump, let's ignore it too.
1717+erl_crash.dump
1818+1919+# Also ignore archive artifacts (built via "mix archive.build").
2020+*.ez
2121+2222+# Ignore package tarball (built via "mix hex.build").
2323+rockbox_ex-*.tar
2424+
+21
sdk/elixir/LICENSE
···11+MIT License
22+33+Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app>
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+675
sdk/elixir/README.md
···11+# rockbox_ex
22+33+Idiomatic Elixir SDK for [Rockbox Zig](https://github.com/tsirysndr/rockbox-zig) — a fully typed
44+GraphQL client for `rockboxd` with real-time WebSocket subscriptions, a
55+plugin behaviour, and a builder DSL for smart playlists.
66+77+- **Pipe-friendly** — every API function takes the client as its first arg.
88+- **Builder-friendly** — smart-playlist rules and partial settings updates
99+ compose with `|>`.
1010+- **Tagged tuples or bangs** — `name/N → {:ok, value} | {:error, exception}`,
1111+ with a matching `name!/N` that raises.
1212+- **Real-time events as messages** — `Rockbox.subscribe(:track_changed)` and
1313+ receive `{:rockbox, :track_changed, %Rockbox.Track{}}`.
1414+- **Plugins** — implement `Rockbox.Plugin` and install with
1515+ `Rockbox.use_plugin/2`.
1616+1717+---
1818+1919+## Table of contents
2020+2121+- [Installation](#installation)
2222+- [Quick start](#quick-start)
2323+- [Configuration](#configuration)
2424+- [API reference](#api-reference)
2525+ - [Playback](#playback)
2626+ - [Library](#library)
2727+ - [Queue (live playlist)](#queue-live-playlist)
2828+ - [Saved playlists](#saved-playlists)
2929+ - [Smart playlists](#smart-playlists)
3030+ - [Sound](#sound)
3131+ - [Settings](#settings)
3232+ - [System](#system)
3333+ - [Browse (filesystem)](#browse-filesystem)
3434+ - [Devices](#devices)
3535+ - [Bluetooth](#bluetooth)
3636+- [Real-time events](#real-time-events)
3737+- [Plugins](#plugins)
3838+- [Error handling](#error-handling)
3939+- [Raw GraphQL queries](#raw-graphql-queries)
4040+4141+---
4242+4343+## Installation
4444+4545+```elixir
4646+def deps do
4747+ [
4848+ {:rockbox_ex, "~> 0.1"}
4949+ ]
5050+end
5151+```
5252+5353+`rockboxd` must be running and reachable. By default the SDK connects to
5454+`http://localhost:6062/graphql`. Start rockboxd with:
5555+5656+```sh
5757+rockbox start
5858+```
5959+6060+---
6161+6262+## Quick start
6363+6464+```elixir
6565+client = Rockbox.new()
6666+6767+# Optional: open the WebSocket so subscribers receive events
6868+{:ok, _pid} = Rockbox.connect(client)
6969+7070+# What's playing right now?
7171+case Rockbox.Playback.current_track(client) do
7272+ {:ok, %Rockbox.Track{} = t} -> IO.puts("▶ #{t.title} — #{t.artist}")
7373+ {:ok, nil} -> IO.puts("Nothing is playing.")
7474+end
7575+7676+# Search the library
7777+{:ok, results} = Rockbox.Library.search(client, "dark side")
7878+album = List.first(results.albums)
7979+8080+# Play it shuffled
8181+:ok = Rockbox.Playback.play_album(client, album.id, shuffle: true)
8282+8383+# React to track changes
8484+:ok = Rockbox.subscribe(:track_changed)
8585+8686+receive do
8787+ {:rockbox, :track_changed, track} ->
8888+ IO.puts("Now: #{track.title}")
8989+end
9090+9191+# Tear down when done
9292+Rockbox.disconnect(client)
9393+```
9494+9595+---
9696+9797+## Configuration
9898+9999+```elixir
100100+# Defaults: localhost:6062
101101+client = Rockbox.new()
102102+103103+# Custom host and port
104104+client = Rockbox.new(host: "192.168.1.42", port: 6062)
105105+106106+# Fully custom URLs (useful behind a reverse proxy)
107107+client = Rockbox.new(
108108+ http_url: "https://music.home/graphql",
109109+ ws_url: "wss://music.home/graphql"
110110+)
111111+```
112112+113113+| Option | Type | Default | Description |
114114+|-------------|------------------------|----------------------------------|-----------------------------------------------------|
115115+| `:host` | `String.t()` | `"localhost"` | Hostname or IP of rockboxd |
116116+| `:port` | `non_neg_integer()` | `6062` | GraphQL HTTP/WS port |
117117+| `:http_url` | `String.t()` | `http://{host}:{port}/graphql` | Override the full HTTP URL |
118118+| `:ws_url` | `String.t()` | `ws://{host}:{port}/graphql` | Override the full WebSocket URL |
119119+| `:headers` | `[{String.t(), String.t()}]` | `[]` | Extra HTTP request headers |
120120+| `:timeout` | `non_neg_integer()` | `15_000` | HTTP request timeout (ms) |
121121+122122+---
123123+124124+## API reference
125125+126126+Every function comes in two flavors:
127127+128128+- `name/N → {:ok, value} | {:error, exception}` — for `with`/`case` pipelines.
129129+- `name!/N → value` — raises `Rockbox.Error` (or a subclass) on failure.
130130+131131+### Playback
132132+133133+```elixir
134134+# Status — returns an atom: :stopped | :playing | :paused
135135+{:ok, :playing} = Rockbox.Playback.status(client)
136136+137137+# Toggle
138138+case Rockbox.Playback.status!(client) do
139139+ :playing -> Rockbox.Playback.pause(client)
140140+ _ -> Rockbox.Playback.resume(client)
141141+end
142142+143143+# Transport
144144+:ok = Rockbox.Playback.next(client)
145145+:ok = Rockbox.Playback.previous(client)
146146+:ok = Rockbox.Playback.stop(client)
147147+148148+# Seek to absolute position (ms)
149149+:ok = Rockbox.Playback.seek(client, 90_000)
150150+151151+# Current / next track — returns nil when stopped
152152+{:ok, %Rockbox.Track{title: t}} = Rockbox.Playback.current_track(client)
153153+{:ok, _next} = Rockbox.Playback.next_track(client)
154154+155155+# Play helpers — single-call shortcuts
156156+:ok = Rockbox.Playback.play_track(client, "/Music/foo.mp3")
157157+:ok = Rockbox.Playback.play_album(client, "album-id", shuffle: true)
158158+:ok = Rockbox.Playback.play_artist(client, "artist-id", shuffle: true)
159159+:ok = Rockbox.Playback.play_playlist(client, "playlist-id")
160160+:ok = Rockbox.Playback.play_directory(client, "/Music/Jazz", recurse: true, shuffle: true)
161161+:ok = Rockbox.Playback.play_liked_tracks(client, shuffle: true)
162162+:ok = Rockbox.Playback.play_all_tracks(client, shuffle: true)
163163+```
164164+165165+`Rockbox.Track` exposes a couple of helpers:
166166+167167+```elixir
168168+Rockbox.Track.format_length(track) # "4:32"
169169+Rockbox.Track.format_elapsed(track) # "1:14"
170170+Rockbox.Track.progress(track) # 0.27 (0.0–1.0)
171171+```
172172+173173+### Library
174174+175175+```elixir
176176+# Albums
177177+{:ok, albums} = Rockbox.Library.albums(client)
178178+{:ok, album} = Rockbox.Library.album(client, "album-id") # full track list
179179+{:ok, liked} = Rockbox.Library.liked_albums(client)
180180+:ok = Rockbox.Library.like_album(client, "album-id")
181181+:ok = Rockbox.Library.unlike_album(client, "album-id")
182182+183183+# Artists
184184+{:ok, artists} = Rockbox.Library.artists(client)
185185+{:ok, artist} = Rockbox.Library.artist(client, "artist-id")
186186+187187+# Tracks
188188+{:ok, tracks} = Rockbox.Library.tracks(client)
189189+{:ok, track} = Rockbox.Library.track(client, "track-id")
190190+{:ok, liked} = Rockbox.Library.liked_tracks(client)
191191+:ok = Rockbox.Library.like_track(client, "track-id")
192192+:ok = Rockbox.Library.unlike_track(client, "track-id")
193193+194194+# Search across artists, albums, tracks, liked
195195+{:ok, results} = Rockbox.Library.search(client, "radiohead")
196196+results.artists # [%Rockbox.Artist{}, ...]
197197+results.albums # [%Rockbox.Album{}, ...]
198198+results.tracks # [%Rockbox.Track{}, ...]
199199+results.liked_tracks
200200+results.liked_albums
201201+202202+# Trigger a full library rescan
203203+:ok = Rockbox.Library.scan(client)
204204+```
205205+206206+### Queue (live playlist)
207207+208208+The *queue* is the live playback list — what plays right now. For persistent
209209+named collections see [Saved playlists](#saved-playlists).
210210+211211+```elixir
212212+{:ok, queue} = Rockbox.Queue.current(client)
213213+queue.amount # total tracks
214214+queue.index # 0-based position of the currently playing track
215215+queue.tracks # [%Rockbox.Track{}, ...]
216216+217217+Rockbox.Playlist.current_track(queue) # convenience helper
218218+219219+# Insertion: position is :next | :after_current | :last | :first
220220+:ok = Rockbox.Queue.insert_tracks(client, ["/Music/a.mp3", "/Music/b.mp3"], :next)
221221+:ok = Rockbox.Queue.insert_directory(client, "/Music/Ambient", :last)
222222+:ok = Rockbox.Queue.insert_album(client, "album-id", :next)
223223+224224+# Other ops
225225+:ok = Rockbox.Queue.remove_track(client, 2)
226226+:ok = Rockbox.Queue.clear(client)
227227+:ok = Rockbox.Queue.shuffle(client)
228228+:ok = Rockbox.Queue.create(client, "Evening Mix", ["/a.mp3", "/b.mp3"])
229229+:ok = Rockbox.Queue.resume(client)
230230+231231+# Pipe-friendly chaining with bang variants
232232+client
233233+|> tap(&Rockbox.Queue.clear!/1)
234234+|> tap(&Rockbox.Queue.insert_tracks!(&1, ["/Music/a.mp3"], :last))
235235+|> Rockbox.Queue.shuffle!()
236236+```
237237+238238+### Saved playlists
239239+240240+```elixir
241241+{:ok, lists} = Rockbox.SavedPlaylists.list(client)
242242+{:ok, lists} = Rockbox.SavedPlaylists.list(client, "folder-id")
243243+244244+{:ok, pl} = Rockbox.SavedPlaylists.get(client, "playlist-id")
245245+{:ok, ids} = Rockbox.SavedPlaylists.track_ids(client, "playlist-id")
246246+247247+# Create
248248+{:ok, pl} =
249249+ Rockbox.SavedPlaylists.create(client,
250250+ name: "Late Night Jazz",
251251+ description: "Quiet music for working",
252252+ folder_id: "folder-id", # optional
253253+ track_ids: ["t1", "t2", "t3"] # optional
254254+ )
255255+256256+# Update / add / remove
257257+:ok = Rockbox.SavedPlaylists.update(client, pl.id, name: "Late Night Jazz (v2)")
258258+:ok = Rockbox.SavedPlaylists.add_tracks(client, pl.id, ["t4", "t5"])
259259+:ok = Rockbox.SavedPlaylists.remove_track(client, pl.id, "t1")
260260+261261+# Play / delete
262262+:ok = Rockbox.SavedPlaylists.play(client, pl.id)
263263+:ok = Rockbox.SavedPlaylists.delete(client, pl.id)
264264+265265+# Folders
266266+{:ok, folders} = Rockbox.SavedPlaylists.folders(client)
267267+{:ok, folder} = Rockbox.SavedPlaylists.create_folder(client, "Work")
268268+:ok = Rockbox.SavedPlaylists.delete_folder(client, folder.id)
269269+```
270270+271271+### Smart playlists
272272+273273+Use the `Rockbox.SmartPlaylist.Rules` builder — pipe-friendly, type-safe.
274274+275275+```elixir
276276+alias Rockbox.SmartPlaylist.Rules
277277+278278+rules =
279279+ Rules.all_of()
280280+ |> Rules.where(:play_count, :gte, 10)
281281+ |> Rules.where(:last_played, :within, "30d")
282282+ |> Rules.sort(:play_count, :desc)
283283+ |> Rules.limit(50)
284284+ |> Rules.to_json()
285285+286286+{:ok, sp} =
287287+ Rockbox.SmartPlaylists.create(client,
288288+ name: "Most played (last 30d)",
289289+ description: "Top 50 most-played tracks from the last month",
290290+ rules: rules
291291+ )
292292+293293+{:ok, ids} = Rockbox.SmartPlaylists.track_ids(client, sp.id)
294294+:ok = Rockbox.SmartPlaylists.play(client, sp.id)
295295+:ok = Rockbox.SmartPlaylists.delete(client, sp.id)
296296+297297+# OR groups
298298+or_rules =
299299+ Rules.any_of()
300300+ |> Rules.where(:title, :contains, "Live")
301301+ |> Rules.where(:title, :contains, "Acoustic")
302302+303303+# Mixed AND/OR via where_group/2
304304+mixed =
305305+ Rules.all_of()
306306+ |> Rules.where(:play_count, :gt, 0)
307307+ |> Rules.where_group(or_rules)
308308+ |> Rules.to_json()
309309+```
310310+311311+#### Listening stats
312312+313313+```elixir
314314+{:ok, stats} = Rockbox.SmartPlaylists.track_stats(client, "track-id")
315315+316316+# Record events manually (e.g. from a scrobbler plugin)
317317+:ok = Rockbox.SmartPlaylists.record_played(client, "track-id")
318318+:ok = Rockbox.SmartPlaylists.record_skipped(client, "track-id")
319319+```
320320+321321+### Sound
322322+323323+Volume is adjusted in firmware-defined steps. The number of steps per dB
324324+varies by hardware target — always inspect `volume/1` for the range.
325325+326326+```elixir
327327+{:ok, %Rockbox.Volume{volume: v, min: lo, max: hi}} = Rockbox.Sound.volume(client)
328328+329329+{:ok, new_value} = Rockbox.Sound.adjust(client, 3) # +3 steps
330330+{:ok, _} = Rockbox.Sound.up(client) # +1
331331+{:ok, _} = Rockbox.Sound.down(client) # -1
332332+```
333333+334334+### Settings
335335+336336+`update/2` accepts any subset of fields — only the ones you pass are written.
337337+338338+```elixir
339339+{:ok, settings} = Rockbox.Settings.get(client)
340340+341341+# Toggle shuffle + repeat
342342+:ok = Rockbox.Settings.update(client, shuffle: true, repeat_mode: 1)
343343+344344+# Equalizer
345345+:ok =
346346+ Rockbox.Settings.update(client,
347347+ eq_enabled: true,
348348+ eq_precut: -3,
349349+ eq_band_settings: [
350350+ %{cutoff: 60, q: 7, gain: 3},
351351+ %{cutoff: 200, q: 7, gain: 0},
352352+ %{cutoff: 4000, q: 7, gain: -2}
353353+ ]
354354+ )
355355+356356+# Compressor
357357+:ok =
358358+ Rockbox.Settings.update(client,
359359+ compressor_settings: %{
360360+ threshold: -24, makeup_gain: 3, ratio: 2,
361361+ knee: 0, release_time: 100, attack_time: 5
362362+ }
363363+ )
364364+365365+# Replaygain
366366+:ok =
367367+ Rockbox.Settings.update(client,
368368+ replaygain_settings: %{noclip: true, type: 1, preamp: 0}
369369+ )
370370+```
371371+372372+### System
373373+374374+```elixir
375375+{:ok, version} = Rockbox.System.version(client)
376376+{:ok, status} = Rockbox.System.status(client)
377377+378378+status.runtime # seconds since boot
379379+status.topruntime # peak runtime
380380+status.resume_index # last queued position
381381+```
382382+383383+### Browse (filesystem)
384384+385385+```elixir
386386+{:ok, entries} = Rockbox.Browse.entries(client) # music_dir root
387387+{:ok, entries} = Rockbox.Browse.entries(client, "/Music/Pink Floyd")
388388+389389+for e <- entries do
390390+ icon = if Rockbox.Entry.directory?(e), do: "📁", else: "🎵"
391391+ IO.puts("#{icon} #{e.name}")
392392+end
393393+394394+{:ok, dirs} = Rockbox.Browse.directories(client, "/Music")
395395+{:ok, files} = Rockbox.Browse.files(client, "/Music/Pink Floyd/The Wall")
396396+```
397397+398398+### Devices
399399+400400+```elixir
401401+{:ok, devices} = Rockbox.Devices.list(client)
402402+{:ok, device} = Rockbox.Devices.get(client, "device-id")
403403+404404+# Connect — switches the active PCM output sink to this device
405405+:ok = Rockbox.Devices.connect(client, "chromecast-id")
406406+:ok = Rockbox.Devices.disconnect(client, "chromecast-id")
407407+```
408408+409409+### Bluetooth
410410+411411+Linux only — backed by BlueZ. Calls return a `Rockbox.GraphQLError` on
412412+non-Linux hosts.
413413+414414+```elixir
415415+{:ok, devices} = Rockbox.Bluetooth.devices(client)
416416+{:ok, found} = Rockbox.Bluetooth.scan(client, 10) # 10 second scan
417417+:ok = Rockbox.Bluetooth.connect(client, "AA:BB:CC:DD:EE:FF")
418418+:ok = Rockbox.Bluetooth.disconnect(client, "AA:BB:CC:DD:EE:FF")
419419+```
420420+421421+---
422422+423423+## Real-time events
424424+425425+Open the WebSocket once with `Rockbox.connect/1`. The connection is supervised
426426+and auto-reconnects with exponential backoff (capped at 30 s). Subscribers
427427+receive plain Erlang messages, so they integrate with `receive` blocks and
428428+`GenServer.handle_info/2`.
429429+430430+```elixir
431431+client = Rockbox.new()
432432+{:ok, _pid} = Rockbox.connect(client)
433433+434434+:ok = Rockbox.subscribe(:track_changed)
435435+:ok = Rockbox.subscribe([:status_changed, :playlist_changed]) # multiple
436436+:ok = Rockbox.subscribe(:all) # catch-all
437437+438438+receive do
439439+ {:rockbox, :track_changed, %Rockbox.Track{} = track} ->
440440+ IO.puts("▶ #{track.title} — #{track.artist}")
441441+442442+ {:rockbox, :status_changed, status} ->
443443+ IO.puts("status → #{status}") # :stopped | :playing | :paused
444444+445445+ {:rockbox, :playlist_changed, %Rockbox.Playlist{} = queue} ->
446446+ IO.puts("queue is now #{queue.amount} tracks")
447447+end
448448+449449+Rockbox.unsubscribe(:track_changed)
450450+Rockbox.disconnect(client)
451451+```
452452+453453+### Event map
454454+455455+| Event | Payload |
456456+|----------------------|-----------------------------------------------|
457457+| `:track_changed` | `%Rockbox.Track{}` |
458458+| `:status_changed` | `:stopped | :playing | :paused` |
459459+| `:playlist_changed` | `%Rockbox.Playlist{}` |
460460+| `:ws_open` | `nil` |
461461+| `:ws_close` | `nil` |
462462+| `:ws_error` | `Exception.t()` |
463463+464464+Subscribers are auto-removed when their process exits — no manual cleanup
465465+needed.
466466+467467+### Inside a GenServer
468468+469469+```elixir
470470+defmodule MyApp.NowPlaying do
471471+ use GenServer
472472+473473+ def start_link(client), do: GenServer.start_link(__MODULE__, client, name: __MODULE__)
474474+475475+ @impl true
476476+ def init(client) do
477477+ Rockbox.connect(client)
478478+ Rockbox.subscribe([:track_changed, :status_changed])
479479+ {:ok, %{client: client, track: nil, status: :stopped}}
480480+ end
481481+482482+ @impl true
483483+ def handle_info({:rockbox, :track_changed, track}, state),
484484+ do: {:noreply, %{state | track: track}}
485485+486486+ def handle_info({:rockbox, :status_changed, status}, state),
487487+ do: {:noreply, %{state | status: status}}
488488+end
489489+```
490490+491491+---
492492+493493+## Plugins
494494+495495+Plugins are the recommended way to bolt on cross-cutting features — scrobbling,
496496+desktop notifications, analytics, sleep timers — without forking the SDK.
497497+498498+### Writing a plugin
499499+500500+```elixir
501501+defmodule MyApp.LastFmScrobbler do
502502+ @behaviour Rockbox.Plugin
503503+504504+ @impl true
505505+ def name, do: "lastfm-scrobbler"
506506+ @impl true
507507+ def version, do: "1.0.0"
508508+ @impl true
509509+ def description, do: "Scrobble played tracks to Last.fm"
510510+511511+ @impl true
512512+ def install(ctx) do
513513+ {:ok, pid} = MyApp.LastFmScrobbler.Worker.start_link(ctx.client)
514514+ {:ok, %{worker: pid}}
515515+ end
516516+517517+ @impl true
518518+ def uninstall(%{worker: pid}) do
519519+ if Process.alive?(pid), do: GenServer.stop(pid)
520520+ :ok
521521+ end
522522+end
523523+524524+defmodule MyApp.LastFmScrobbler.Worker do
525525+ use GenServer
526526+527527+ def start_link(client), do: GenServer.start_link(__MODULE__, client)
528528+529529+ @impl true
530530+ def init(client) do
531531+ Rockbox.Events.subscribe(:track_changed)
532532+ {:ok, %{client: client, current: nil, started_at: 0}}
533533+ end
534534+535535+ @impl true
536536+ def handle_info({:rockbox, :track_changed, track}, state) do
537537+ now = System.monotonic_time(:millisecond)
538538+539539+ # Submit the previous track if it played for more than 30 s
540540+ if state.current && now - state.started_at > 30_000 do
541541+ submit_scrobble(state.current)
542542+ end
543543+544544+ {:noreply, %{state | current: track, started_at: now}}
545545+ end
546546+547547+ defp submit_scrobble(_track), do: :ok # talk to the Last.fm API here
548548+end
549549+```
550550+551551+### Installing
552552+553553+```elixir
554554+client = Rockbox.new()
555555+{:ok, _} = Rockbox.connect(client)
556556+557557+:ok = Rockbox.use_plugin(client, MyApp.LastFmScrobbler)
558558+559559+# Inspect what's installed
560560+for entry <- Rockbox.installed_plugins() do
561561+ IO.puts("#{entry.module.name()} v#{entry.module.version()}")
562562+end
563563+564564+:ok = Rockbox.unuse_plugin("lastfm-scrobbler") # by name
565565+:ok = Rockbox.unuse_plugin(MyApp.LastFmScrobbler) # or by module
566566+```
567567+568568+The `install/1` callback receives `%{client: client}`. Return `{:ok, state}`;
569569+the state is passed back to `uninstall/1` so resources can be cleaned up.
570570+571571+---
572572+573573+## Error handling
574574+575575+```elixir
576576+case Rockbox.Playback.play(client) do
577577+ :ok ->
578578+ :ok
579579+580580+ {:error, %Rockbox.NetworkError{} = e} ->
581581+ Logger.error("rockboxd unreachable: #{Exception.message(e)}")
582582+583583+ {:error, %Rockbox.GraphQLError{errors: errors}} ->
584584+ for %{message: msg} <- errors, do: Logger.error("graphql: #{msg}")
585585+586586+ {:error, %Rockbox.Error{} = e} ->
587587+ Logger.error("rockbox: #{Exception.message(e)}")
588588+end
589589+590590+# …or use the bang variant inside a try/rescue
591591+try do
592592+ Rockbox.Playback.play!(client)
593593+rescue
594594+ e in Rockbox.NetworkError -> Logger.error("offline: #{e.message}")
595595+ e in Rockbox.GraphQLError -> Logger.error("server: #{e.message}")
596596+end
597597+```
598598+599599+| Exception | When raised |
600600+|---------------------------|----------------------------------------------------------|
601601+| `Rockbox.NetworkError` | HTTP request fails or returns a non-2xx status |
602602+| `Rockbox.GraphQLError` | Server returns `{ "errors": [...] }` in the response body |
603603+| `Rockbox.Error` | Base exception — rescue this to catch any SDK failure |
604604+605605+---
606606+607607+## Raw GraphQL queries
608608+609609+For operations not yet covered by a dedicated function, drop down to
610610+`Rockbox.query/3`. Variables can be a map or keyword list — snake_case keys
611611+are converted to camelCase before being sent.
612612+613613+```elixir
614614+{:ok, %{"rockboxVersion" => v}} =
615615+ Rockbox.query(client, "query { rockboxVersion }")
616616+617617+{:ok, %{"album" => album}} =
618618+ Rockbox.query(
619619+ client,
620620+ "query Album($id: String!) { album(id: $id) { id title artist year } }",
621621+ id: "abc-123"
622622+ )
623623+624624+# Mutation
625625+:ok = Rockbox.query(client, "mutation Seek($t: Int!) { fastForwardRewind(newTime: $t) }", t: 120_000) |> elem(0) == :ok
626626+```
627627+628628+The GraphiQL explorer is available at `http://localhost:6062/graphiql` while
629629+rockboxd is running.
630630+631631+---
632632+633633+## Module map
634634+635635+| Domain | Module |
636636+|-----------------------|---------------------------------|
637637+| Client constructor | `Rockbox`, `Rockbox.Client` |
638638+| Transport controls | `Rockbox.Playback` |
639639+| Library / search | `Rockbox.Library` |
640640+| Live queue | `Rockbox.Queue` |
641641+| Saved playlists | `Rockbox.SavedPlaylists` |
642642+| Smart playlists | `Rockbox.SmartPlaylists` |
643643+| Smart-playlist rules | `Rockbox.SmartPlaylist.Rules` |
644644+| Volume | `Rockbox.Sound` |
645645+| Settings | `Rockbox.Settings` |
646646+| System info | `Rockbox.System` |
647647+| Filesystem browser | `Rockbox.Browse` |
648648+| Output devices | `Rockbox.Devices` |
649649+| Bluetooth | `Rockbox.Bluetooth` |
650650+| Real-time events | `Rockbox.Events` |
651651+| Plugin behaviour | `Rockbox.Plugin`, `Rockbox.Plugins` |
652652+| Errors | `Rockbox.Error`, `Rockbox.NetworkError`, `Rockbox.GraphQLError` |
653653+654654+---
655655+656656+## Development
657657+658658+```sh
659659+mix deps.get
660660+mix test
661661+mix docs # generates HTML docs in doc/
662662+```
663663+664664+Examples live in `examples/`. Start `rockboxd`, then:
665665+666666+```sh
667667+mix run examples/01_basic_playback.exs
668668+mix run --no-halt examples/02_now_playing.exs
669669+```
670670+671671+---
672672+673673+## License
674674+675675+MIT License. See [LICENSE](./LICENSE) for details.
+47
sdk/elixir/examples/01_basic_playback.exs
···11+# 01 — Basic playback
22+#
33+# Inspect the current track, then either pause or resume based on the current
44+# state. Idempotent: run it twice and it toggles between Playing and Paused.
55+#
66+# mix run examples/01_basic_playback.exs
77+88+Code.require_file("_helper.exs", __DIR__)
99+1010+client = Examples.Helper.client()
1111+1212+{:ok, status} = Rockbox.Playback.status(client)
1313+IO.puts("Status: #{status}")
1414+1515+case Rockbox.Playback.current_track(client) do
1616+ {:ok, %Rockbox.Track{} = track} ->
1717+ pct =
1818+ if track.length > 0,
1919+ do: round(track.elapsed / track.length * 100),
2020+ else: 0
2121+2222+ IO.puts("Now: #{track.title} — #{track.artist}")
2323+2424+ IO.puts(
2525+ " #{Rockbox.Track.format_elapsed(track)} / #{Rockbox.Track.format_length(track)} (#{pct}%)"
2626+ )
2727+2828+ {:ok, nil} ->
2929+ IO.puts("Nothing is playing.")
3030+3131+ {:error, e} ->
3232+ IO.puts(:stderr, "Error: #{Exception.message(e)}")
3333+ System.halt(1)
3434+end
3535+3636+case status do
3737+ :playing ->
3838+ :ok = Rockbox.Playback.pause(client)
3939+ IO.puts("→ paused")
4040+4141+ :paused ->
4242+ :ok = Rockbox.Playback.resume(client)
4343+ IO.puts("→ resumed")
4444+4545+ _ ->
4646+ :ok
4747+end
+29
sdk/elixir/examples/02_now_playing.exs
···11+# 02 — Real-time "Now Playing" stream
22+#
33+# Open the WebSocket and print track changes as they happen.
44+#
55+# mix run --no-halt examples/02_now_playing.exs
66+77+Code.require_file("_helper.exs", __DIR__)
88+99+client = Examples.Helper.client()
1010+{:ok, _pid} = Rockbox.connect(client)
1111+:ok = Rockbox.subscribe([:track_changed, :status_changed])
1212+1313+IO.puts("Listening for events. Press Ctrl+C to exit.")
1414+1515+defmodule Loop do
1616+ def run do
1717+ receive do
1818+ {:rockbox, :track_changed, %Rockbox.Track{} = t} ->
1919+ IO.puts("▶ #{t.title} — #{t.artist} (#{Rockbox.format_ms(t.length)})")
2020+ run()
2121+2222+ {:rockbox, :status_changed, status} ->
2323+ IO.puts("· status → #{status}")
2424+ run()
2525+ end
2626+ end
2727+end
2828+2929+Loop.run()
···11+defmodule Rockbox.Entry do
22+ @moduledoc """
33+ A filesystem entry returned by `Rockbox.Browse`. `:attr` is a bitmask;
44+ use `directory?/1` instead of inspecting it directly.
55+ """
66+77+ @type t :: %__MODULE__{
88+ name: String.t(),
99+ attr: integer(),
1010+ time_write: integer(),
1111+ customaction: integer(),
1212+ display_name: String.t() | nil
1313+ }
1414+1515+ defstruct [:name, :attr, :time_write, :customaction, :display_name]
1616+1717+ @doc "Returns `true` when the entry is a directory (attr bit 4 set)."
1818+ @spec directory?(t()) :: boolean()
1919+ def directory?(%__MODULE__{attr: attr}) when is_integer(attr), do: Bitwise.band(attr, 0x10) != 0
2020+ def directory?(_), do: false
2121+2222+ @doc "Returns `true` when the entry is a regular file."
2323+ @spec file?(t()) :: boolean()
2424+ def file?(entry), do: not directory?(entry)
2525+end
+11
sdk/elixir/lib/rockbox/eq_band.ex
···11+defmodule Rockbox.EqBand do
22+ @moduledoc "A single equalizer band setting."
33+44+ @type t :: %__MODULE__{
55+ cutoff: integer(),
66+ q: integer(),
77+ gain: integer()
88+ }
99+1010+ defstruct [:cutoff, :q, :gain]
1111+end
+59
sdk/elixir/lib/rockbox/error.ex
···11+defmodule Rockbox.Error do
22+ @moduledoc """
33+ Base exception for the Rockbox SDK.
44+55+ Two specialised subclasses are raised in practice:
66+77+ * `Rockbox.NetworkError` — the HTTP request failed or returned a non-2xx
88+ status code (rockboxd unreachable, refused connection, timeout, …).
99+ * `Rockbox.GraphQLError` — rockboxd answered with a structured GraphQL
1010+ error response. The original error list is available on the
1111+ `:errors` field.
1212+1313+ Both inherit from `Rockbox.Error`, so a single rescue clause catches every
1414+ SDK-originated failure:
1515+1616+ try do
1717+ Rockbox.Playback.play!(client)
1818+ rescue
1919+ e in Rockbox.NetworkError -> Logger.error("offline: \#{e.message}")
2020+ e in Rockbox.GraphQLError -> Logger.error("server error: \#{e.message}")
2121+ e in Rockbox.Error -> Logger.error("rockbox: \#{e.message}")
2222+ end
2323+ """
2424+2525+ defexception [:message, :cause]
2626+2727+ @type t :: %__MODULE__{message: String.t(), cause: term() | nil}
2828+end
2929+3030+defmodule Rockbox.NetworkError do
3131+ @moduledoc "Raised when the HTTP layer cannot reach rockboxd."
3232+3333+ defexception [:message, :cause]
3434+3535+ @type t :: %__MODULE__{message: String.t(), cause: term() | nil}
3636+end
3737+3838+defmodule Rockbox.GraphQLError do
3939+ @moduledoc "Raised when rockboxd returns a structured GraphQL error response."
4040+4141+ defexception [:message, :errors]
4242+4343+ @type error_detail :: %{
4444+ required(:message) => String.t(),
4545+ optional(:path) => [String.t() | non_neg_integer()],
4646+ optional(:locations) => [%{line: non_neg_integer(), column: non_neg_integer()}],
4747+ optional(:extensions) => map()
4848+ }
4949+5050+ @type t :: %__MODULE__{message: String.t(), errors: [error_detail()]}
5151+5252+ @impl true
5353+ def exception(errors) when is_list(errors) do
5454+ %__MODULE__{
5555+ message: errors |> Enum.map_join("; ", &Map.get(&1, :message, "GraphQL error")),
5656+ errors: errors
5757+ }
5858+ end
5959+end
+82
sdk/elixir/lib/rockbox/events.ex
···11+defmodule Rockbox.Events do
22+ @moduledoc """
33+ Subscribe the calling process to real-time events emitted by rockboxd.
44+55+ Events arrive as plain Erlang messages so they integrate naturally with
66+ `receive` blocks and `GenServer.handle_info/2`:
77+88+ {:ok, _pid} = Rockbox.connect(client)
99+ :ok = Rockbox.Events.subscribe(:track_changed)
1010+1111+ receive do
1212+ {:rockbox, :track_changed, %Rockbox.Track{} = track} ->
1313+ IO.puts("Now playing: \#{track.title}")
1414+ end
1515+1616+ ## Event names and payloads
1717+1818+ | event | payload |
1919+ |--------------------|--------------------------------------------|
2020+ | `:track_changed` | `Rockbox.Track.t()` |
2121+ | `:status_changed` | `:stopped | :playing | :paused` |
2222+ | `:playlist_changed`| `Rockbox.Playlist.t()` |
2323+ | `:ws_open` | `nil` |
2424+ | `:ws_close` | `nil` |
2525+ | `:ws_error` | `Exception.t()` |
2626+2727+ Subscribers are auto-removed when their process exits, so you never need to
2828+ manually clean up.
2929+ """
3030+3131+ @typedoc "All event names emitted by the SDK."
3232+ @type event ::
3333+ :track_changed
3434+ | :status_changed
3535+ | :playlist_changed
3636+ | :ws_open
3737+ | :ws_close
3838+ | :ws_error
3939+4040+ @typedoc "All event names plus `:all` for catch-all subscribers."
4141+ @type subscription :: event() | :all
4242+4343+ @doc """
4444+ Subscribe the calling process to one or more events.
4545+4646+ Pass a single atom (or `:all` for catch-all), or a list of atoms.
4747+ """
4848+ @spec subscribe(subscription() | [subscription()]) :: :ok
4949+ def subscribe(event) when is_atom(event) do
5050+ {:ok, _} = Registry.register(Rockbox.Subscribers, event, [])
5151+ :ok
5252+ end
5353+5454+ def subscribe(events) when is_list(events) do
5555+ Enum.each(events, &subscribe/1)
5656+ end
5757+5858+ @doc "Unsubscribe the calling process from an event."
5959+ @spec unsubscribe(subscription()) :: :ok
6060+ def unsubscribe(event) when is_atom(event) do
6161+ Registry.unregister(Rockbox.Subscribers, event)
6262+ end
6363+6464+ @doc false
6565+ @spec broadcast(event(), term()) :: :ok
6666+ def broadcast(event, payload) do
6767+ deliver(event, payload)
6868+ deliver(:all, {event, payload})
6969+ :ok
7070+ end
7171+7272+ defp deliver(key, payload) do
7373+ Registry.dispatch(Rockbox.Subscribers, key, fn entries ->
7474+ for {pid, _} <- entries do
7575+ send(pid, message(key, payload))
7676+ end
7777+ end)
7878+ end
7979+8080+ defp message(:all, {event, payload}), do: {:rockbox, event, payload}
8181+ defp message(event, payload), do: {:rockbox, event, payload}
8282+end
+285
sdk/elixir/lib/rockbox/library.ex
···11+defmodule Rockbox.Library do
22+ @moduledoc """
33+ Browse and manage the music library — albums, artists, tracks, likes,
44+ search, and rescan.
55+ """
66+77+ alias Rockbox.{Album, Artist, Client, SearchResults, Track, Transport, Util}
88+99+ @track_fields ~S"""
1010+ fragment TrackFields on Track {
1111+ id title artist album genre disc trackString yearString
1212+ composer comment albumArtist grouping
1313+ discnum tracknum layer year bitrate frequency
1414+ filesize length elapsed path
1515+ albumId artistId genreId albumArt
1616+ }
1717+ """
1818+1919+ @album_fields ~S"""
2020+ fragment AlbumFields on Album {
2121+ id title artist year yearString albumArt md5 artistId copyrightMessage
2222+ }
2323+ """
2424+2525+ @artist_fields ~S"""
2626+ fragment ArtistFields on Artist {
2727+ id name bio image
2828+ }
2929+ """
3030+3131+ # ---------------------------------------------------------------------------
3232+ # Albums
3333+ # ---------------------------------------------------------------------------
3434+3535+ @doc "List every album. Each album's `:tracks` are stub records (id/title/path/length/album_art)."
3636+ @spec albums(Client.t()) :: {:ok, [Album.t()]} | {:error, Exception.t()}
3737+ def albums(client) do
3838+ query =
3939+ @album_fields <>
4040+ "query Albums { albums { ...AlbumFields tracks { id title path length albumArt } } }"
4141+4242+ with {:ok, %{"albums" => list}} <- Transport.execute(client, query) do
4343+ {:ok, Enum.map(list, &album_with_tracks/1)}
4444+ end
4545+ end
4646+4747+ @spec albums!(Client.t()) :: [Album.t()]
4848+ def albums!(client), do: bang(albums(client))
4949+5050+ @doc "Get a single album with full track info, or `{:ok, nil}`."
5151+ @spec album(Client.t(), String.t()) :: {:ok, Album.t() | nil} | {:error, Exception.t()}
5252+ def album(client, id) do
5353+ query =
5454+ @track_fields <>
5555+ @album_fields <>
5656+ "query Album($id: String!) { album(id: $id) { ...AlbumFields tracks { ...TrackFields } } }"
5757+5858+ with {:ok, %{"album" => raw}} <- Transport.execute(client, query, %{id: id}) do
5959+ {:ok, album_with_tracks(raw)}
6060+ end
6161+ end
6262+6363+ @spec album!(Client.t(), String.t()) :: Album.t() | nil
6464+ def album!(client, id), do: bang(album(client, id))
6565+6666+ @doc "List albums the user has liked."
6767+ @spec liked_albums(Client.t()) :: {:ok, [Album.t()]} | {:error, Exception.t()}
6868+ def liked_albums(client) do
6969+ query = @album_fields <> "query LikedAlbums { likedAlbums { ...AlbumFields } }"
7070+7171+ with {:ok, %{"likedAlbums" => list}} <- Transport.execute(client, query) do
7272+ {:ok, Util.to_struct_list(Album, list)}
7373+ end
7474+ end
7575+7676+ @spec like_album(Client.t(), String.t()) :: :ok | {:error, Exception.t()}
7777+ def like_album(client, id),
7878+ do:
7979+ void(
8080+ Transport.execute(client, "mutation LikeAlbum($id: String!) { likeAlbum(id: $id) }", %{
8181+ id: id
8282+ })
8383+ )
8484+8585+ @spec unlike_album(Client.t(), String.t()) :: :ok | {:error, Exception.t()}
8686+ def unlike_album(client, id),
8787+ do:
8888+ void(
8989+ Transport.execute(
9090+ client,
9191+ "mutation UnlikeAlbum($id: String!) { unlikeAlbum(id: $id) }",
9292+ %{
9393+ id: id
9494+ }
9595+ )
9696+ )
9797+9898+ # ---------------------------------------------------------------------------
9999+ # Artists
100100+ # ---------------------------------------------------------------------------
101101+102102+ @doc "List every artist with shallow album info."
103103+ @spec artists(Client.t()) :: {:ok, [Artist.t()]} | {:error, Exception.t()}
104104+ def artists(client) do
105105+ query =
106106+ @artist_fields <>
107107+ "query Artists { artists { ...ArtistFields albums { id title albumArt year } } }"
108108+109109+ with {:ok, %{"artists" => list}} <- Transport.execute(client, query) do
110110+ {:ok, Enum.map(list, &artist_with_albums/1)}
111111+ end
112112+ end
113113+114114+ @spec artists!(Client.t()) :: [Artist.t()]
115115+ def artists!(client), do: bang(artists(client))
116116+117117+ @doc "Get a single artist with their albums and tracks."
118118+ @spec artist(Client.t(), String.t()) :: {:ok, Artist.t() | nil} | {:error, Exception.t()}
119119+ def artist(client, id) do
120120+ query =
121121+ @artist_fields <>
122122+ @track_fields <>
123123+ """
124124+ query Artist($id: String!) {
125125+ artist(id: $id) {
126126+ ...ArtistFields
127127+ albums { id title albumArt year yearString md5 artistId tracks { id title path length } }
128128+ tracks { ...TrackFields }
129129+ }
130130+ }
131131+ """
132132+133133+ with {:ok, %{"artist" => raw}} <- Transport.execute(client, query, %{id: id}) do
134134+ {:ok, artist_with_albums(raw)}
135135+ end
136136+ end
137137+138138+ @spec artist!(Client.t(), String.t()) :: Artist.t() | nil
139139+ def artist!(client, id), do: bang(artist(client, id))
140140+141141+ # ---------------------------------------------------------------------------
142142+ # Tracks
143143+ # ---------------------------------------------------------------------------
144144+145145+ @doc "List every track."
146146+ @spec tracks(Client.t()) :: {:ok, [Track.t()]} | {:error, Exception.t()}
147147+ def tracks(client) do
148148+ query = @track_fields <> "query Tracks { tracks { ...TrackFields } }"
149149+150150+ with {:ok, %{"tracks" => list}} <- Transport.execute(client, query) do
151151+ {:ok, Util.to_struct_list(Track, list)}
152152+ end
153153+ end
154154+155155+ @spec tracks!(Client.t()) :: [Track.t()]
156156+ def tracks!(client), do: bang(tracks(client))
157157+158158+ @doc "Get a single track by id, or `{:ok, nil}`."
159159+ @spec track(Client.t(), String.t()) :: {:ok, Track.t() | nil} | {:error, Exception.t()}
160160+ def track(client, id) do
161161+ query = @track_fields <> "query Track($id: String!) { track(id: $id) { ...TrackFields } }"
162162+163163+ with {:ok, %{"track" => raw}} <- Transport.execute(client, query, %{id: id}) do
164164+ {:ok, Util.to_struct(Track, raw)}
165165+ end
166166+ end
167167+168168+ @spec track!(Client.t(), String.t()) :: Track.t() | nil
169169+ def track!(client, id), do: bang(track(client, id))
170170+171171+ @doc "List tracks the user has liked."
172172+ @spec liked_tracks(Client.t()) :: {:ok, [Track.t()]} | {:error, Exception.t()}
173173+ def liked_tracks(client) do
174174+ query = @track_fields <> "query LikedTracks { likedTracks { ...TrackFields } }"
175175+176176+ with {:ok, %{"likedTracks" => list}} <- Transport.execute(client, query) do
177177+ {:ok, Util.to_struct_list(Track, list)}
178178+ end
179179+ end
180180+181181+ @spec like_track(Client.t(), String.t()) :: :ok | {:error, Exception.t()}
182182+ def like_track(client, id),
183183+ do:
184184+ void(
185185+ Transport.execute(client, "mutation LikeTrack($id: String!) { likeTrack(id: $id) }", %{
186186+ id: id
187187+ })
188188+ )
189189+190190+ @spec unlike_track(Client.t(), String.t()) :: :ok | {:error, Exception.t()}
191191+ def unlike_track(client, id),
192192+ do:
193193+ void(
194194+ Transport.execute(
195195+ client,
196196+ "mutation UnlikeTrack($id: String!) { unlikeTrack(id: $id) }",
197197+ %{
198198+ id: id
199199+ }
200200+ )
201201+ )
202202+203203+ # ---------------------------------------------------------------------------
204204+ # Search
205205+ # ---------------------------------------------------------------------------
206206+207207+ @doc "Full-text search across artists, albums and tracks."
208208+ @spec search(Client.t(), String.t()) :: {:ok, SearchResults.t()} | {:error, Exception.t()}
209209+ def search(client, term) do
210210+ query =
211211+ @track_fields <>
212212+ @album_fields <>
213213+ @artist_fields <>
214214+ """
215215+ query Search($term: String!) {
216216+ search(term: $term) {
217217+ artists { ...ArtistFields }
218218+ albums { ...AlbumFields }
219219+ tracks { ...TrackFields }
220220+ likedTracks { ...TrackFields }
221221+ likedAlbums { ...AlbumFields }
222222+ }
223223+ }
224224+ """
225225+226226+ with {:ok, %{"search" => raw}} <- Transport.execute(client, query, %{term: term}) do
227227+ atomized = Util.atomize(raw)
228228+229229+ {:ok,
230230+ %SearchResults{
231231+ artists: Util.to_struct_list(Artist, atomized.artists),
232232+ albums: Util.to_struct_list(Album, atomized.albums),
233233+ tracks: Util.to_struct_list(Track, atomized.tracks),
234234+ liked_tracks: Util.to_struct_list(Track, atomized.liked_tracks),
235235+ liked_albums: Util.to_struct_list(Album, atomized.liked_albums)
236236+ }}
237237+ end
238238+ end
239239+240240+ @spec search!(Client.t(), String.t()) :: SearchResults.t()
241241+ def search!(client, term), do: bang(search(client, term))
242242+243243+ # ---------------------------------------------------------------------------
244244+ # Library management
245245+ # ---------------------------------------------------------------------------
246246+247247+ @doc "Trigger a full rescan of the configured `music_dir`."
248248+ @spec scan(Client.t()) :: :ok | {:error, Exception.t()}
249249+ def scan(client),
250250+ do: void(Transport.execute(client, "mutation ScanLibrary { scanLibrary }"))
251251+252252+ # ---------------------------------------------------------------------------
253253+ # Internal
254254+ # ---------------------------------------------------------------------------
255255+256256+ defp album_with_tracks(nil), do: nil
257257+258258+ defp album_with_tracks(raw) do
259259+ atomized = Util.atomize(raw)
260260+ base = Util.to_struct(Album, raw)
261261+ %{base | tracks: Util.to_struct_list(Track, Map.get(atomized, :tracks, []))}
262262+ end
263263+264264+ defp artist_with_albums(nil), do: nil
265265+266266+ defp artist_with_albums(raw) do
267267+ atomized = Util.atomize(raw)
268268+ base = Util.to_struct(Artist, raw)
269269+270270+ albums =
271271+ atomized
272272+ |> Map.get(:albums, [])
273273+ |> Enum.map(&album_with_tracks/1)
274274+275275+ tracks = Util.to_struct_list(Track, Map.get(atomized, :tracks, []))
276276+ %{base | albums: albums, tracks: tracks}
277277+ end
278278+279279+ defp void({:ok, _}), do: :ok
280280+ defp void(err), do: err
281281+282282+ defp bang({:ok, value}), do: value
283283+ defp bang(:ok), do: :ok
284284+ defp bang({:error, exception}), do: raise(exception)
285285+end
+278
sdk/elixir/lib/rockbox/playback.ex
···11+defmodule Rockbox.Playback do
22+ @moduledoc """
33+ Transport controls and play helpers.
44+55+ Rockbox.new()
66+ |> Rockbox.Playback.play_album("album-id", shuffle: true)
77+88+ Most functions return `:ok | {:error, exception}`. A `!` variant raises on
99+ error. Functions that return data come in `name/1 → {:ok, value}` and
1010+ `name!/1 → value` pairs.
1111+ """
1212+1313+ alias Rockbox.{Client, Track, Transport, Types, Util}
1414+1515+ @track_fields ~S"""
1616+ fragment TrackFields on Track {
1717+ id title artist album genre disc trackString yearString
1818+ composer comment albumArtist grouping
1919+ discnum tracknum layer year bitrate frequency
2020+ filesize length elapsed path
2121+ albumId artistId genreId albumArt
2222+ }
2323+ """
2424+2525+ # ---------------------------------------------------------------------------
2626+ # State
2727+ # ---------------------------------------------------------------------------
2828+2929+ @doc """
3030+ Current playback status as an atom (`:stopped | :playing | :paused`).
3131+3232+ iex> Rockbox.Playback.status(client)
3333+ {:ok, :playing}
3434+ """
3535+ @spec status(Client.t()) :: {:ok, Types.playback_status()} | {:error, Exception.t()}
3636+ def status(client) do
3737+ case Transport.execute(client, "query PlaybackStatus { status }") do
3838+ {:ok, %{"status" => raw}} -> {:ok, Types.playback_status(raw)}
3939+ err -> err
4040+ end
4141+ end
4242+4343+ @spec status!(Client.t()) :: Types.playback_status()
4444+ def status!(client), do: bang(status(client))
4545+4646+ @doc "Raw integer playback status (matches the firmware enum)."
4747+ @spec raw_status(Client.t()) :: {:ok, integer()} | {:error, Exception.t()}
4848+ def raw_status(client) do
4949+ case Transport.execute(client, "query PlaybackStatus { status }") do
5050+ {:ok, %{"status" => raw}} -> {:ok, raw}
5151+ err -> err
5252+ end
5353+ end
5454+5555+ @doc "The currently playing track, or `{:ok, nil}` when stopped."
5656+ @spec current_track(Client.t()) :: {:ok, Track.t() | nil} | {:error, Exception.t()}
5757+ def current_track(client) do
5858+ query = @track_fields <> "query CurrentTrack { currentTrack { ...TrackFields } }"
5959+6060+ case Transport.execute(client, query) do
6161+ {:ok, %{"currentTrack" => raw}} -> {:ok, Util.to_struct(Track, raw)}
6262+ err -> err
6363+ end
6464+ end
6565+6666+ @spec current_track!(Client.t()) :: Track.t() | nil
6767+ def current_track!(client), do: bang(current_track(client))
6868+6969+ @doc "The next track in the queue, or `{:ok, nil}` if there is none."
7070+ @spec next_track(Client.t()) :: {:ok, Track.t() | nil} | {:error, Exception.t()}
7171+ def next_track(client) do
7272+ query = @track_fields <> "query NextTrack { nextTrack { ...TrackFields } }"
7373+7474+ case Transport.execute(client, query) do
7575+ {:ok, %{"nextTrack" => raw}} -> {:ok, Util.to_struct(Track, raw)}
7676+ err -> err
7777+ end
7878+ end
7979+8080+ @spec next_track!(Client.t()) :: Track.t() | nil
8181+ def next_track!(client), do: bang(next_track(client))
8282+8383+ @doc "Byte offset into the currently playing file."
8484+ @spec file_position(Client.t()) :: {:ok, integer()} | {:error, Exception.t()}
8585+ def file_position(client) do
8686+ case Transport.execute(client, "query FilePosition { getFilePosition }") do
8787+ {:ok, %{"getFilePosition" => pos}} -> {:ok, pos}
8888+ err -> err
8989+ end
9090+ end
9191+9292+ # ---------------------------------------------------------------------------
9393+ # Transport controls
9494+ # ---------------------------------------------------------------------------
9595+9696+ @doc "Resume playback from the queued position."
9797+ @spec play(Client.t(), keyword()) :: :ok | {:error, Exception.t()}
9898+ def play(client, opts \\ []) do
9999+ elapsed = Keyword.get(opts, :elapsed, 0)
100100+ offset = Keyword.get(opts, :offset, 0)
101101+102102+ void(
103103+ Transport.execute(
104104+ client,
105105+ "mutation Play($elapsed: Long!, $offset: Long!) { play(elapsed: $elapsed, offset: $offset) }",
106106+ %{elapsed: elapsed, offset: offset}
107107+ )
108108+ )
109109+ end
110110+111111+ @spec play!(Client.t(), keyword()) :: :ok
112112+ def play!(client, opts \\ []), do: bang(play(client, opts))
113113+114114+ for {fun, mutation} <- [
115115+ pause: "mutation Pause { pause }",
116116+ resume: "mutation Resume { resume }",
117117+ next: "mutation Next { next }",
118118+ previous: "mutation Previous { previous }",
119119+ stop: "mutation Stop { hardStop }",
120120+ flush_and_reload: "mutation FlushReload { flushAndReloadTracks }"
121121+ ] do
122122+ @doc "Run the corresponding mutation."
123123+ @spec unquote(fun)(Client.t()) :: :ok | {:error, Exception.t()}
124124+ def unquote(fun)(client), do: void(Transport.execute(client, unquote(mutation)))
125125+126126+ bang_name = String.to_atom("#{fun}!")
127127+ @doc false
128128+ @spec unquote(bang_name)(Client.t()) :: :ok
129129+ def unquote(bang_name)(client), do: bang(unquote(fun)(client))
130130+ end
131131+132132+ @doc "Seek to an absolute position in milliseconds."
133133+ @spec seek(Client.t(), integer()) :: :ok | {:error, Exception.t()}
134134+ def seek(client, position_ms) when is_integer(position_ms) do
135135+ void(
136136+ Transport.execute(
137137+ client,
138138+ "mutation Seek($newTime: Int!) { fastForwardRewind(newTime: $newTime) }",
139139+ %{new_time: position_ms}
140140+ )
141141+ )
142142+ end
143143+144144+ @spec seek!(Client.t(), integer()) :: :ok
145145+ def seek!(client, ms), do: bang(seek(client, ms))
146146+147147+ # ---------------------------------------------------------------------------
148148+ # Play helpers (single-call shortcuts)
149149+ # ---------------------------------------------------------------------------
150150+151151+ @doc "Play a single file by absolute path."
152152+ @spec play_track(Client.t(), String.t()) :: :ok | {:error, Exception.t()}
153153+ def play_track(client, path) do
154154+ void(
155155+ Transport.execute(
156156+ client,
157157+ "mutation PlayTrack($path: String!) { playTrack(path: $path) }",
158158+ %{path: path}
159159+ )
160160+ )
161161+ end
162162+163163+ @spec play_track!(Client.t(), String.t()) :: :ok
164164+ def play_track!(client, path), do: bang(play_track(client, path))
165165+166166+ @doc """
167167+ Play all tracks from an album.
168168+169169+ Options: `:shuffle`, `:position` (start track index).
170170+ """
171171+ @spec play_album(Client.t(), String.t(), keyword()) :: :ok | {:error, Exception.t()}
172172+ def play_album(client, album_id, opts \\ []) do
173173+ vars = Map.merge(%{album_id: album_id}, Map.new(opts))
174174+175175+ void(
176176+ Transport.execute(
177177+ client,
178178+ "mutation PlayAlbum($albumId: String!, $shuffle: Boolean, $position: Int) { playAlbum(albumId: $albumId, shuffle: $shuffle, position: $position) }",
179179+ vars
180180+ )
181181+ )
182182+ end
183183+184184+ @spec play_album!(Client.t(), String.t(), keyword()) :: :ok
185185+ def play_album!(client, id, opts \\ []), do: bang(play_album(client, id, opts))
186186+187187+ @doc "Play all tracks by an artist. Options: `:shuffle`, `:position`."
188188+ @spec play_artist(Client.t(), String.t(), keyword()) :: :ok | {:error, Exception.t()}
189189+ def play_artist(client, artist_id, opts \\ []) do
190190+ vars = Map.merge(%{artist_id: artist_id}, Map.new(opts))
191191+192192+ void(
193193+ Transport.execute(
194194+ client,
195195+ "mutation PlayArtist($artistId: String!, $shuffle: Boolean, $position: Int) { playArtistTracks(artistId: $artistId, shuffle: $shuffle, position: $position) }",
196196+ vars
197197+ )
198198+ )
199199+ end
200200+201201+ @spec play_artist!(Client.t(), String.t(), keyword()) :: :ok
202202+ def play_artist!(client, id, opts \\ []), do: bang(play_artist(client, id, opts))
203203+204204+ @doc "Play a saved playlist by id. Options: `:shuffle`, `:position`."
205205+ @spec play_playlist(Client.t(), String.t(), keyword()) :: :ok | {:error, Exception.t()}
206206+ def play_playlist(client, playlist_id, opts \\ []) do
207207+ vars = Map.merge(%{playlist_id: playlist_id}, Map.new(opts))
208208+209209+ void(
210210+ Transport.execute(
211211+ client,
212212+ "mutation PlayPlaylist($playlistId: String!, $shuffle: Boolean, $position: Int) { playPlaylist(playlistId: $playlistId, shuffle: $shuffle, position: $position) }",
213213+ vars
214214+ )
215215+ )
216216+ end
217217+218218+ @spec play_playlist!(Client.t(), String.t(), keyword()) :: :ok
219219+ def play_playlist!(client, id, opts \\ []), do: bang(play_playlist(client, id, opts))
220220+221221+ @doc "Play every file under a directory. Options: `:recurse`, `:shuffle`, `:position`."
222222+ @spec play_directory(Client.t(), String.t(), keyword()) :: :ok | {:error, Exception.t()}
223223+ def play_directory(client, path, opts \\ []) do
224224+ vars = Map.merge(%{path: path}, Map.new(opts))
225225+226226+ void(
227227+ Transport.execute(
228228+ client,
229229+ "mutation PlayDirectory($path: String!, $recurse: Boolean, $shuffle: Boolean, $position: Int) { playDirectory(path: $path, recurse: $recurse, shuffle: $shuffle, position: $position) }",
230230+ vars
231231+ )
232232+ )
233233+ end
234234+235235+ @spec play_directory!(Client.t(), String.t(), keyword()) :: :ok
236236+ def play_directory!(client, path, opts \\ []), do: bang(play_directory(client, path, opts))
237237+238238+ @doc "Play the user's liked tracks."
239239+ @spec play_liked_tracks(Client.t(), keyword()) :: :ok | {:error, Exception.t()}
240240+ def play_liked_tracks(client, opts \\ []) do
241241+ void(
242242+ Transport.execute(
243243+ client,
244244+ "mutation PlayLikedTracks($shuffle: Boolean, $position: Int) { playLikedTracks(shuffle: $shuffle, position: $position) }",
245245+ Map.new(opts)
246246+ )
247247+ )
248248+ end
249249+250250+ @spec play_liked_tracks!(Client.t(), keyword()) :: :ok
251251+ def play_liked_tracks!(client, opts \\ []), do: bang(play_liked_tracks(client, opts))
252252+253253+ @doc "Play the entire library — typically with `shuffle: true`."
254254+ @spec play_all_tracks(Client.t(), keyword()) :: :ok | {:error, Exception.t()}
255255+ def play_all_tracks(client, opts \\ []) do
256256+ void(
257257+ Transport.execute(
258258+ client,
259259+ "mutation PlayAllTracks($shuffle: Boolean, $position: Int) { playAllTracks(shuffle: $shuffle, position: $position) }",
260260+ Map.new(opts)
261261+ )
262262+ )
263263+ end
264264+265265+ @spec play_all_tracks!(Client.t(), keyword()) :: :ok
266266+ def play_all_tracks!(client, opts \\ []), do: bang(play_all_tracks(client, opts))
267267+268268+ # ---------------------------------------------------------------------------
269269+ # Internal
270270+ # ---------------------------------------------------------------------------
271271+272272+ defp void({:ok, _}), do: :ok
273273+ defp void(err), do: err
274274+275275+ defp bang({:ok, value}), do: value
276276+ defp bang(:ok), do: :ok
277277+ defp bang({:error, exception}), do: raise(exception)
278278+end
+35
sdk/elixir/lib/rockbox/playlist.ex
···11+defmodule Rockbox.Playlist do
22+ @moduledoc """
33+ A snapshot of the live playback queue. `:index` is the 0-based position of
44+ the currently playing track in `:tracks`.
55+ """
66+77+ @type t :: %__MODULE__{
88+ amount: integer(),
99+ index: integer(),
1010+ max_playlist_size: integer(),
1111+ first_index: integer(),
1212+ last_insert_pos: integer(),
1313+ seed: integer(),
1414+ last_shuffled_start: integer(),
1515+ tracks: [Rockbox.Track.t()]
1616+ }
1717+1818+ defstruct [
1919+ :amount,
2020+ :index,
2121+ :max_playlist_size,
2222+ :first_index,
2323+ :last_insert_pos,
2424+ :seed,
2525+ :last_shuffled_start,
2626+ tracks: []
2727+ ]
2828+2929+ @doc "The currently playing track, or `nil` if the queue is empty."
3030+ @spec current_track(t()) :: Rockbox.Track.t() | nil
3131+ def current_track(%__MODULE__{tracks: tracks, index: i}) when is_list(tracks) and i >= 0,
3232+ do: Enum.at(tracks, i)
3333+3434+ def current_track(_), do: nil
3535+end
+55
sdk/elixir/lib/rockbox/plugin.ex
···11+defmodule Rockbox.Plugin do
22+ @moduledoc """
33+ Behaviour for Rockbox plugins. A plugin is a regular module implementing
44+ `install/1` (and optionally `uninstall/1`).
55+66+ defmodule MyApp.LastFmScrobbler do
77+ @behaviour Rockbox.Plugin
88+99+ @impl true
1010+ def name, do: "lastfm-scrobbler"
1111+ @impl true
1212+ def version, do: "1.0.0"
1313+ @impl true
1414+ def description, do: "Scrobble played tracks to Last.fm"
1515+1616+ @impl true
1717+ def install(ctx) do
1818+ # Subscribe like any other process — events arrive as messages.
1919+ Rockbox.Events.subscribe(:track_changed)
2020+ {:ok, %{client: ctx.client, started_at: System.monotonic_time(:millisecond)}}
2121+ end
2222+2323+ @impl true
2424+ def uninstall(_state), do: :ok
2525+ end
2626+2727+ Install with `Rockbox.use_plugin(client, MyApp.LastFmScrobbler)`. Plugins
2828+ receive a `t:context/0` map containing the client they were installed with.
2929+3030+ Heavy plugins (those that need their own process) should spawn it inside
3131+ `install/1` and store its pid in their state — `uninstall/1` will be called
3232+ with that state and can shut the process down.
3333+ """
3434+3535+ @type context :: %{client: Rockbox.Client.t()}
3636+ @type state :: term()
3737+3838+ @callback name() :: String.t()
3939+ @callback version() :: String.t()
4040+ @callback description() :: String.t()
4141+ @callback install(context()) :: {:ok, state()} | :ok | {:error, term()}
4242+ @callback uninstall(state()) :: :ok | {:error, term()}
4343+4444+ @optional_callbacks description: 0, uninstall: 1
4545+4646+ @doc false
4747+ def description(plugin) do
4848+ if function_exported?(plugin, :description, 0), do: plugin.description(), else: nil
4949+ end
5050+5151+ @doc false
5252+ def uninstall(plugin, state) do
5353+ if function_exported?(plugin, :uninstall, 1), do: plugin.uninstall(state), else: :ok
5454+ end
5555+end
+81
sdk/elixir/lib/rockbox/plugins.ex
···11+defmodule Rockbox.Plugins do
22+ @moduledoc """
33+ Registry of installed `Rockbox.Plugin` modules. You normally interact with
44+ it through `Rockbox.use_plugin/2`, `Rockbox.unuse_plugin/2`, and
55+ `Rockbox.installed_plugins/0`.
66+ """
77+88+ use GenServer
99+1010+ alias Rockbox.{Client, Plugin}
1111+1212+ @type entry :: %{module: module(), state: term(), client: Client.t()}
1313+1414+ # ---------------------------------------------------------------------------
1515+ # Public API
1616+ # ---------------------------------------------------------------------------
1717+1818+ @spec start_link(term()) :: GenServer.on_start()
1919+ def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
2020+2121+ @spec install(Client.t(), module()) :: :ok | {:error, term()}
2222+ def install(client, plugin), do: GenServer.call(__MODULE__, {:install, client, plugin})
2323+2424+ @spec uninstall(module() | String.t()) :: :ok
2525+ def uninstall(name_or_module), do: GenServer.call(__MODULE__, {:uninstall, name_or_module})
2626+2727+ @spec list() :: [entry()]
2828+ def list, do: GenServer.call(__MODULE__, :list)
2929+3030+ # ---------------------------------------------------------------------------
3131+ # GenServer
3232+ # ---------------------------------------------------------------------------
3333+3434+ @impl true
3535+ def init(_), do: {:ok, %{}}
3636+3737+ @impl true
3838+ def handle_call({:install, client, plugin}, _from, state) do
3939+ name = plugin.name()
4040+4141+ if Map.has_key?(state, name) do
4242+ {:reply, {:error, :already_installed}, state}
4343+ else
4444+ ctx = %{client: client}
4545+4646+ case plugin.install(ctx) do
4747+ {:ok, plugin_state} ->
4848+ entry = %{module: plugin, state: plugin_state, client: client}
4949+ {:reply, :ok, Map.put(state, name, entry)}
5050+5151+ :ok ->
5252+ entry = %{module: plugin, state: nil, client: client}
5353+ {:reply, :ok, Map.put(state, name, entry)}
5454+5555+ {:error, _} = err ->
5656+ {:reply, err, state}
5757+ end
5858+ end
5959+ end
6060+6161+ def handle_call({:uninstall, key}, _from, state) do
6262+ name = if is_atom(key), do: try_name(key, key), else: key
6363+6464+ case Map.pop(state, name) do
6565+ {nil, state} ->
6666+ {:reply, :ok, state}
6767+6868+ {%{module: mod, state: pstate}, state} ->
6969+ Plugin.uninstall(mod, pstate)
7070+ {:reply, :ok, state}
7171+ end
7272+ end
7373+7474+ def handle_call(:list, _from, state) do
7575+ {:reply, Map.values(state), state}
7676+ end
7777+7878+ defp try_name(plugin, fallback) do
7979+ if function_exported?(plugin, :name, 0), do: plugin.name(), else: to_string(fallback)
8080+ end
8181+end
+178
sdk/elixir/lib/rockbox/queue.ex
···11+defmodule Rockbox.Queue do
22+ @moduledoc """
33+ The live playback queue (called *playlist* in the GraphQL schema). For
44+ persistent named collections see `Rockbox.SavedPlaylists`.
55+66+ Rockbox.new()
77+ |> Rockbox.Queue.insert_tracks(["/Music/a.mp3", "/Music/b.mp3"], :next)
88+ """
99+1010+ alias Rockbox.{Client, Playlist, Track, Transport, Types, Util}
1111+1212+ @track_fields ~S"""
1313+ fragment TrackFields on Track {
1414+ id title artist album genre disc trackString yearString
1515+ composer comment albumArtist grouping
1616+ discnum tracknum layer year bitrate frequency
1717+ filesize length elapsed path
1818+ albumId artistId genreId albumArt
1919+ }
2020+ """
2121+2222+ @doc "Snapshot of the active queue."
2323+ @spec current(Client.t()) :: {:ok, Playlist.t()} | {:error, Exception.t()}
2424+ def current(client) do
2525+ query =
2626+ @track_fields <>
2727+ """
2828+ query CurrentPlaylist {
2929+ playlistGetCurrent {
3030+ amount index maxPlaylistSize firstIndex
3131+ lastInsertPos seed lastShuffledStart
3232+ tracks { ...TrackFields }
3333+ }
3434+ }
3535+ """
3636+3737+ with {:ok, %{"playlistGetCurrent" => raw}} <- Transport.execute(client, query) do
3838+ atomized = Util.atomize(raw)
3939+ base = Util.to_struct(Playlist, raw)
4040+ {:ok, %{base | tracks: Util.to_struct_list(Track, Map.get(atomized, :tracks, []))}}
4141+ end
4242+ end
4343+4444+ @spec current!(Client.t()) :: Playlist.t()
4545+ def current!(client), do: bang(current(client))
4646+4747+ @doc "Number of tracks currently queued."
4848+ @spec amount(Client.t()) :: {:ok, integer()} | {:error, Exception.t()}
4949+ def amount(client) do
5050+ case Transport.execute(client, "query PlaylistAmount { playlistAmount }") do
5151+ {:ok, %{"playlistAmount" => n}} -> {:ok, n}
5252+ err -> err
5353+ end
5454+ end
5555+5656+ # ---------------------------------------------------------------------------
5757+ # Insert / remove
5858+ # ---------------------------------------------------------------------------
5959+6060+ @doc """
6161+ Insert one or more file paths (or track ids) into the queue.
6262+6363+ `position` may be an atom (`:next`, `:after_current`, `:last`, `:first`) or
6464+ the matching integer. `playlist_id` is optional — omit to target the active
6565+ queue.
6666+6767+ Rockbox.Queue.insert_tracks(client, ["/Music/a.mp3"], :next)
6868+ """
6969+ @spec insert_tracks(Client.t(), [String.t()], Types.insert_position(), String.t() | nil) ::
7070+ :ok | {:error, Exception.t()}
7171+ def insert_tracks(client, paths, position \\ :next, playlist_id \\ nil) do
7272+ vars = %{playlist_id: playlist_id, position: Types.insert_position(position), tracks: paths}
7373+7474+ void(
7575+ Transport.execute(
7676+ client,
7777+ "mutation InsertTracks($playlistId: String, $position: Int!, $tracks: [String!]!) { insertTracks(playlistId: $playlistId, position: $position, tracks: $tracks) }",
7878+ vars
7979+ )
8080+ )
8181+ end
8282+8383+ @doc "Insert every file under a directory into the queue."
8484+ @spec insert_directory(Client.t(), String.t(), Types.insert_position(), String.t() | nil) ::
8585+ :ok | {:error, Exception.t()}
8686+ def insert_directory(client, directory, position \\ :last, playlist_id \\ nil) do
8787+ vars = %{
8888+ playlist_id: playlist_id,
8989+ position: Types.insert_position(position),
9090+ directory: directory
9191+ }
9292+9393+ void(
9494+ Transport.execute(
9595+ client,
9696+ "mutation InsertDirectory($playlistId: String, $position: Int!, $directory: String!) { insertDirectory(playlistId: $playlistId, position: $position, directory: $directory) }",
9797+ vars
9898+ )
9999+ )
100100+ end
101101+102102+ @doc "Insert every track from an album into the queue."
103103+ @spec insert_album(Client.t(), String.t(), Types.insert_position()) ::
104104+ :ok | {:error, Exception.t()}
105105+ def insert_album(client, album_id, position \\ :last) do
106106+ vars = %{album_id: album_id, position: Types.insert_position(position)}
107107+108108+ void(
109109+ Transport.execute(
110110+ client,
111111+ "mutation InsertAlbum($albumId: String!, $position: Int!) { insertAlbum(albumId: $albumId, position: $position) }",
112112+ vars
113113+ )
114114+ )
115115+ end
116116+117117+ @doc "Remove the track at the given 0-based queue index."
118118+ @spec remove_track(Client.t(), non_neg_integer()) :: :ok | {:error, Exception.t()}
119119+ def remove_track(client, index) do
120120+ void(
121121+ Transport.execute(
122122+ client,
123123+ "mutation RemoveTrack($index: Int!) { playlistRemoveTrack(index: $index) }",
124124+ %{index: index}
125125+ )
126126+ )
127127+ end
128128+129129+ @doc "Empty the queue."
130130+ @spec clear(Client.t()) :: :ok | {:error, Exception.t()}
131131+ def clear(client),
132132+ do: void(Transport.execute(client, "mutation ClearPlaylist { playlistRemoveAllTracks }"))
133133+134134+ @doc "Reshuffle the queue in place."
135135+ @spec shuffle(Client.t()) :: :ok | {:error, Exception.t()}
136136+ def shuffle(client),
137137+ do: void(Transport.execute(client, "mutation ShufflePlaylist { shufflePlaylist }"))
138138+139139+ @doc "Create a new temporary queue (replaces the current one) and start playing."
140140+ @spec create(Client.t(), String.t(), [String.t()]) :: :ok | {:error, Exception.t()}
141141+ def create(client, name, tracks) do
142142+ void(
143143+ Transport.execute(
144144+ client,
145145+ "mutation CreatePlaylist($name: String!, $tracks: [String!]!) { playlistCreate(name: $name, tracks: $tracks) }",
146146+ %{name: name, tracks: tracks}
147147+ )
148148+ )
149149+ end
150150+151151+ @doc "Begin playback of the current queue. Options: `:start_index`, `:elapsed`, `:offset`."
152152+ @spec start(Client.t(), keyword()) :: :ok | {:error, Exception.t()}
153153+ def start(client, opts \\ []) do
154154+ void(
155155+ Transport.execute(
156156+ client,
157157+ "mutation PlaylistStart($startIndex: Int, $elapsed: Int, $offset: Int) { playlistStart(startIndex: $startIndex, elapsed: $elapsed, offset: $offset) }",
158158+ Map.new(opts)
159159+ )
160160+ )
161161+ end
162162+163163+ @doc "Resume the queue from where playback was last stopped."
164164+ @spec resume(Client.t()) :: :ok | {:error, Exception.t()}
165165+ def resume(client),
166166+ do: void(Transport.execute(client, "mutation PlaylistResume { playlistResume }"))
167167+168168+ # ---------------------------------------------------------------------------
169169+ # Internal
170170+ # ---------------------------------------------------------------------------
171171+172172+ defp void({:ok, _}), do: :ok
173173+ defp void(err), do: err
174174+175175+ defp bang({:ok, value}), do: value
176176+ defp bang(:ok), do: :ok
177177+ defp bang({:error, exception}), do: raise(exception)
178178+end
···11+defmodule Rockbox.EntryTest do
22+ use ExUnit.Case, async: true
33+44+ alias Rockbox.Entry
55+66+ test "directory? checks the 0x10 bit" do
77+ assert Entry.directory?(%Entry{name: "x", attr: 0x10})
88+ refute Entry.directory?(%Entry{name: "x", attr: 0x00})
99+ end
1010+1111+ test "file? is the inverse" do
1212+ assert Entry.file?(%Entry{name: "x", attr: 0x00})
1313+ refute Entry.file?(%Entry{name: "x", attr: 0x10})
1414+ end
1515+end
+32
sdk/elixir/test/rockbox/events_test.exs
···11+defmodule Rockbox.EventsTest do
22+ use ExUnit.Case, async: false
33+44+ alias Rockbox.Events
55+66+ test "subscriber receives broadcast" do
77+ Events.subscribe(:track_changed)
88+ Events.broadcast(:track_changed, %Rockbox.Track{title: "Hello"})
99+ assert_receive {:rockbox, :track_changed, %Rockbox.Track{title: "Hello"}}, 200
1010+ end
1111+1212+ test ":all subscriber gets every event" do
1313+ Events.subscribe(:all)
1414+ Events.broadcast(:status_changed, :playing)
1515+ assert_receive {:rockbox, :status_changed, :playing}, 200
1616+ end
1717+1818+ test "unsubscribe stops delivery" do
1919+ Events.subscribe(:track_changed)
2020+ Events.unsubscribe(:track_changed)
2121+ Events.broadcast(:track_changed, %Rockbox.Track{title: "ignored"})
2222+ refute_receive {:rockbox, :track_changed, _}, 100
2323+ end
2424+2525+ test "subscribe/1 with a list" do
2626+ Events.subscribe([:track_changed, :status_changed])
2727+ Events.broadcast(:status_changed, :playing)
2828+ Events.broadcast(:track_changed, %Rockbox.Track{title: "x"})
2929+ assert_receive {:rockbox, :status_changed, :playing}
3030+ assert_receive {:rockbox, :track_changed, %Rockbox.Track{}}
3131+ end
3232+end
+59
sdk/elixir/test/rockbox/plugins_test.exs
···11+defmodule Rockbox.PluginsTest do
22+ use ExUnit.Case, async: false
33+44+ defmodule HelloPlugin do
55+ @behaviour Rockbox.Plugin
66+77+ @impl true
88+ def name, do: "hello"
99+ @impl true
1010+ def version, do: "0.1.0"
1111+ @impl true
1212+ def description, do: "Test plugin"
1313+1414+ @impl true
1515+ def install(ctx) do
1616+ {pid, _} = :persistent_term.get({__MODULE__, :test_pid}, {nil, nil})
1717+ if pid, do: send(pid, {:installed, ctx.client})
1818+ {:ok, %{installed_at: System.system_time()}}
1919+ end
2020+2121+ @impl true
2222+ def uninstall(_state) do
2323+ {pid, _} = :persistent_term.get({__MODULE__, :test_pid}, {nil, nil})
2424+ if pid, do: send(pid, :uninstalled)
2525+ :ok
2626+ end
2727+ end
2828+2929+ setup do
3030+ :persistent_term.put({HelloPlugin, :test_pid}, {self(), make_ref()})
3131+ Rockbox.Plugins.uninstall("hello")
3232+3333+ on_exit(fn ->
3434+ Rockbox.Plugins.uninstall("hello")
3535+ end)
3636+3737+ :ok
3838+ end
3939+4040+ test "install / list / uninstall" do
4141+ client = Rockbox.new()
4242+4343+ assert :ok = Rockbox.use_plugin(client, HelloPlugin)
4444+ assert_receive {:installed, %Rockbox.Client{}}
4545+4646+ names = Enum.map(Rockbox.installed_plugins(), & &1.module.name())
4747+ assert "hello" in names
4848+4949+ assert :ok = Rockbox.unuse_plugin("hello")
5050+ assert_receive :uninstalled
5151+ end
5252+5353+ test "double-install is rejected" do
5454+ client = Rockbox.new()
5555+ assert :ok = Rockbox.use_plugin(client, HelloPlugin)
5656+ assert_receive {:installed, _}
5757+ assert {:error, :already_installed} = Rockbox.use_plugin(client, HelloPlugin)
5858+ end
5959+end
···11+defmodule Rockbox.TypesTest do
22+ use ExUnit.Case, async: true
33+44+ alias Rockbox.Types
55+66+ test "playback_status round-trips" do
77+ for atom <- [:stopped, :playing, :paused] do
88+ assert Types.playback_status(Types.from_playback_status(atom)) == atom
99+ end
1010+ end
1111+1212+ test "repeat_mode round-trips" do
1313+ for atom <- [:off, :all, :one, :shuffle, :ab_repeat] do
1414+ assert Types.repeat_mode(Types.from_repeat_mode(atom)) == atom
1515+ end
1616+ end
1717+1818+ test "channel_config round-trips" do
1919+ for atom <- [:stereo, :stereo_narrow, :mono, :left_mix, :right_mix, :karaoke] do
2020+ assert Types.channel_config(Types.from_channel_config(atom)) == atom
2121+ end
2222+ end
2323+2424+ test "replaygain_type round-trips" do
2525+ for atom <- [:track, :album, :shuffle] do
2626+ assert Types.replaygain_type(Types.from_replaygain_type(atom)) == atom
2727+ end
2828+ end
2929+3030+ test "insert_position accepts atoms and ints" do
3131+ assert Types.insert_position(:next) == 0
3232+ assert Types.insert_position(:after_current) == 1
3333+ assert Types.insert_position(:last) == 2
3434+ assert Types.insert_position(:first) == 3
3535+ assert Types.insert_position(7) == 7
3636+ end
3737+3838+ test "unknown values surface as {:unknown, n}" do
3939+ assert Types.playback_status(99) == {:unknown, 99}
4040+ end
4141+end
+59
sdk/elixir/test/rockbox/util_test.exs
···11+defmodule Rockbox.UtilTest do
22+ use ExUnit.Case, async: true
33+44+ alias Rockbox.Util
55+66+ describe "atomize/1" do
77+ test "converts camelCase string keys to snake_case atoms" do
88+ assert Util.atomize(%{"trackNum" => 1, "albumArt" => "x"}) == %{
99+ track_num: 1,
1010+ album_art: "x"
1111+ }
1212+ end
1313+1414+ test "recurses into nested maps" do
1515+ input = %{"outer" => %{"innerKey" => 1}}
1616+ assert Util.atomize(input) == %{outer: %{inner_key: 1}}
1717+ end
1818+1919+ test "recurses into lists of maps" do
2020+ input = [%{"firstName" => "a"}, %{"firstName" => "b"}]
2121+ assert Util.atomize(input) == [%{first_name: "a"}, %{first_name: "b"}]
2222+ end
2323+2424+ test "leaves non-map/list values alone" do
2525+ assert Util.atomize("hi") == "hi"
2626+ assert Util.atomize(42) == 42
2727+ assert Util.atomize(nil) == nil
2828+ end
2929+ end
3030+3131+ describe "camelize/1" do
3232+ test "converts snake_case atom keys to camelCase strings" do
3333+ assert Util.camelize(%{album_id: "x", play_count: 5}) ==
3434+ %{"albumId" => "x", "playCount" => 5}
3535+ end
3636+3737+ test "passes through values" do
3838+ assert Util.camelize(%{name: "foo"}) == %{"name" => "foo"}
3939+ end
4040+4141+ test "round-trips through atomize/1" do
4242+ original = %{"trackNum" => 1, "nested" => %{"theKey" => 42}}
4343+ assert original |> Util.atomize() |> Util.camelize() == original
4444+ end
4545+ end
4646+4747+ describe "to_struct/2" do
4848+ test "builds a struct, ignoring unknown keys" do
4949+ raw = %{"id" => "x", "title" => "Hi", "extraField" => "ignored"}
5050+ track = Util.to_struct(Rockbox.Track, raw)
5151+ assert track.id == "x"
5252+ assert track.title == "Hi"
5353+ end
5454+5555+ test "returns nil for nil input" do
5656+ assert Util.to_struct(Rockbox.Track, nil) == nil
5757+ end
5858+ end
5959+end
+45
sdk/elixir/test/rockbox_test.exs
···11+defmodule RockboxTest do
22+ use ExUnit.Case, async: true
33+ doctest Rockbox
44+55+ describe "new/1" do
66+ test "defaults" do
77+ client = Rockbox.new()
88+ assert client.host == "localhost"
99+ assert client.port == 6062
1010+ assert client.http_url == "http://localhost:6062/graphql"
1111+ assert client.ws_url == "ws://localhost:6062/graphql"
1212+ end
1313+1414+ test "host + port override" do
1515+ client = Rockbox.new(host: "192.168.1.10", port: 7000)
1616+ assert client.http_url == "http://192.168.1.10:7000/graphql"
1717+ assert client.ws_url == "ws://192.168.1.10:7000/graphql"
1818+ end
1919+2020+ test "http_url override takes precedence" do
2121+ client = Rockbox.new(http_url: "https://music.home/api")
2222+ assert client.http_url == "https://music.home/api"
2323+ end
2424+ end
2525+2626+ describe "format_ms/1" do
2727+ test "formats sub-minute durations" do
2828+ assert Rockbox.format_ms(45_000) == "0:45"
2929+ end
3030+3131+ test "formats minute-and-seconds" do
3232+ assert Rockbox.format_ms(75_000) == "1:15"
3333+ assert Rockbox.format_ms(180_000) == "3:00"
3434+ end
3535+3636+ test "pads single-digit seconds" do
3737+ assert Rockbox.format_ms(61_000) == "1:01"
3838+ end
3939+4040+ test "negative or invalid -> 0:00" do
4141+ assert Rockbox.format_ms(-1) == "0:00"
4242+ assert Rockbox.format_ms(nil) == "0:00"
4343+ end
4444+ end
4545+end
···11+MIT License
22+33+Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app>
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+30
sdk/gleam/README.md
···11+# rockbox
22+33+[](https://hex.pm/packages/rockbox)
44+[](https://hexdocs.pm/rockbox/)
55+66+```sh
77+gleam add rockbox@1
88+```
99+```gleam
1010+import rockbox
1111+1212+pub fn main() -> Nil {
1313+ // TODO: An example of the project in use
1414+}
1515+```
1616+1717+Further documentation can be found at <https://hexdocs.pm/rockbox>.
1818+1919+## Development
2020+2121+```sh
2222+gleam run # Run the project
2323+gleam test # Run the tests
2424+```
2525+2626+---
2727+2828+## License
2929+3030+MIT License. See [LICENSE](./LICENSE) for details.
+16
sdk/gleam/gleam.toml
···11+name = "rockbox"
22+version = "1.0.0"
33+description = "Gleam SDK for Rockbox — pipe-friendly client for the rockboxd GraphQL API"
44+licences = ["Apache-2.0"]
55+gleam = ">= 1.11.0"
66+77+# repository = { type = "github", user = "tsirysndr", repo = "rockbox-zig" }
88+99+[dependencies]
1010+gleam_stdlib = ">= 0.46.0 and < 2.0.0"
1111+gleam_http = ">= 4.0.0 and < 5.0.0"
1212+gleam_httpc = ">= 5.0.0 and < 6.0.0"
1313+gleam_json = ">= 2.0.0 and < 4.0.0"
1414+1515+[dev_dependencies]
1616+gleeunit = ">= 1.0.0 and < 2.0.0"
+18
sdk/gleam/manifest.toml
···11+# This file was generated by Gleam
22+# You typically do not need to edit this file
33+44+packages = [
55+ { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
66+ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
77+ { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" },
88+ { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
99+ { name = "gleam_stdlib", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "960090C2FB391784BB34267B099DC9315CC1B1F6013E7415BC763CEF1905D7D3" },
1010+ { name = "gleeunit", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "254B697FE72EEAD7BF82E941723918E421317813AC49923EE76A18C788C61E72" },
1111+]
1212+1313+[requirements]
1414+gleam_http = { version = ">= 4.0.0 and < 5.0.0" }
1515+gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" }
1616+gleam_json = { version = ">= 2.0.0 and < 4.0.0" }
1717+gleam_stdlib = { version = ">= 0.46.0 and < 2.0.0" }
1818+gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+161
sdk/gleam/src/rockbox.gleam
···11+//// Rockbox Gleam SDK — pipe-friendly client for the rockboxd GraphQL API.
22+////
33+//// ```gleam
44+//// import rockbox
55+//// import rockbox/playback
66+////
77+//// pub fn main() {
88+//// let client = rockbox.connect()
99+////
1010+//// let assert Ok(track) = playback.current_track(client)
1111+//// let assert Ok(_) = playback.pause(client)
1212+//// }
1313+//// ```
1414+////
1515+//// Customise the connection with the builder:
1616+////
1717+//// ```gleam
1818+//// let client =
1919+//// rockbox.new()
2020+//// |> rockbox.host("rockbox.local")
2121+//// |> rockbox.port(8080)
2222+//// |> rockbox.connect
2323+//// ```
2424+2525+import gleam/dynamic/decode
2626+import gleam/int
2727+import gleam/json.{type Json}
2828+import gleam/option.{type Option, None, Some}
2929+import rockbox/error.{type Error}
3030+import rockbox/internal/transport.{type Transport, Transport}
3131+3232+// ---------------------------------------------------------------------------
3333+// Types
3434+// ---------------------------------------------------------------------------
3535+3636+/// A configured client. Hand it to any of the per-domain API modules:
3737+///
3838+/// ```gleam
3939+/// playback.play(client, 0, 0)
4040+/// library.search(client, "miles davis")
4141+/// ```
4242+pub opaque type Client {
4343+ Client(transport: Transport)
4444+}
4545+4646+/// A fluent builder used to configure a `Client`.
4747+///
4848+/// Construct with `new`, override defaults with `host` / `port` / `url`, then
4949+/// call `connect` (or `build`, the alias) to get a `Client`.
5050+pub opaque type Builder {
5151+ Builder(host: String, port: Int, url_override: Option(String))
5252+}
5353+5454+// ---------------------------------------------------------------------------
5555+// Builder API
5656+// ---------------------------------------------------------------------------
5757+5858+/// Start a new client builder with sensible defaults (`localhost:6062`).
5959+pub fn new() -> Builder {
6060+ Builder(host: "localhost", port: 6062, url_override: None)
6161+}
6262+6363+/// Override the hostname (default `"localhost"`). Ignored if `url` is set.
6464+pub fn host(builder: Builder, value: String) -> Builder {
6565+ Builder(..builder, host: value)
6666+}
6767+6868+/// Override the port (default `6062`). Ignored if `url` is set.
6969+pub fn port(builder: Builder, value: Int) -> Builder {
7070+ Builder(..builder, port: value)
7171+}
7272+7373+/// Override the full GraphQL HTTP URL. Takes precedence over `host` / `port`.
7474+pub fn url(builder: Builder, value: String) -> Builder {
7575+ Builder(..builder, url_override: Some(value))
7676+}
7777+7878+/// Finalise the builder and return a usable `Client`.
7979+pub fn connect(builder: Builder) -> Client {
8080+ let http_url = case builder.url_override {
8181+ Some(value) -> value
8282+ None ->
8383+ "http://"
8484+ <> builder.host
8585+ <> ":"
8686+ <> int.to_string(builder.port)
8787+ <> "/graphql"
8888+ }
8989+ Client(transport: Transport(http_url: http_url))
9090+}
9191+9292+/// Alias for `connect`. Use whichever name reads better in your code.
9393+pub fn build(builder: Builder) -> Client {
9494+ connect(builder)
9595+}
9696+9797+// ---------------------------------------------------------------------------
9898+// Convenience constructors
9999+// ---------------------------------------------------------------------------
100100+101101+/// Shortcut for `new() |> connect()` — a client pointed at `localhost:6062`.
102102+pub fn default_client() -> Client {
103103+ new() |> connect
104104+}
105105+106106+/// Shortcut for `new() |> host(host) |> port(port) |> connect()`.
107107+pub fn at(host h: String, port p: Int) -> Client {
108108+ new() |> host(h) |> port(p) |> connect
109109+}
110110+111111+// ---------------------------------------------------------------------------
112112+// Escape hatches & accessors
113113+// ---------------------------------------------------------------------------
114114+115115+/// Run a raw GraphQL operation against the server, decoding the `data` field
116116+/// with the supplied decoder. Useful if you need an endpoint the SDK doesn't
117117+/// expose directly yet.
118118+///
119119+/// ```gleam
120120+/// import gleam/dynamic/decode
121121+/// import gleam/json
122122+///
123123+/// let decoder = {
124124+/// use version <- decode.field("rockboxVersion", decode.string)
125125+/// decode.success(version)
126126+/// }
127127+///
128128+/// let assert Ok(version) =
129129+/// client
130130+/// |> rockbox.query("query { rockboxVersion }", json.object([]), decoder)
131131+/// ```
132132+pub fn query(
133133+ client: Client,
134134+ gql: String,
135135+ variables: Json,
136136+ decoder: decode.Decoder(t),
137137+) -> Result(t, Error) {
138138+ transport.execute(client.transport, gql, variables, decoder)
139139+}
140140+141141+/// Like `query` but discards the response body — handy for fire-and-forget
142142+/// mutations that return a boolean status flag you don't care about.
143143+pub fn execute(
144144+ client: Client,
145145+ gql: String,
146146+ variables: Json,
147147+) -> Result(Nil, Error) {
148148+ transport.execute_unit(client.transport, gql, variables)
149149+}
150150+151151+/// Return the underlying GraphQL HTTP URL. Useful for diagnostics & tests.
152152+pub fn http_url(client: Client) -> String {
153153+ client.transport.http_url
154154+}
155155+156156+/// Internal: hand the underlying transport to other modules in the SDK.
157157+@internal
158158+pub fn transport(client: Client) -> Transport {
159159+ client.transport
160160+}
161161+
···11+MIT License
22+33+Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app>
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+224
sdk/python/README.md
···11+# rockbox-sdk
22+33+Async Python SDK for [Rockbox](https://www.rockbox.org) — a typed, batteries-included
44+client for the GraphQL API exposed by `rockboxd`.
55+66+```python
77+import asyncio
88+from rockbox_sdk import RockboxClient, PlaybackStatus
99+1010+async def main():
1111+ async with RockboxClient(host="localhost") as client:
1212+ track = await client.playback.current_track()
1313+ if track:
1414+ print(f"Now: {track.title} — {track.artist}")
1515+ if await client.playback.status() == PlaybackStatus.PAUSED:
1616+ await client.playback.resume()
1717+1818+asyncio.run(main())
1919+```
2020+2121+## Highlights
2222+2323+- **Async-first** — built on `httpx` + `websockets`. Use `await` everywhere.
2424+- **Domain-namespaced API** — `client.playback.*`, `client.library.*`, `client.sound.*`, …
2525+- **Typed responses** — every reply is a Pydantic model with snake_case fields.
2626+- **Real-time events** — `connect()` opens a WebSocket and forwards
2727+ `track:changed` / `status:changed` / `playlist:changed` to listeners.
2828+- **Builder API** — `RockboxClient.builder().host(...).port(...).build()`.
2929+- **Plugin system** — Jellyfin-style install/uninstall lifecycle.
3030+- **Python-friendly** — context manager, decorator listeners, dataclass inputs.
3131+3232+## Install
3333+3434+```sh
3535+uv add rockbox-sdk
3636+# or
3737+pip install rockbox-sdk
3838+```
3939+4040+Requires Python 3.10+ and a running `rockboxd` (default port 6062).
4141+4242+## Try it in the REPL
4343+4444+The SDK is async-first, so the easiest way to poke at a live `rockboxd` is
4545+Python's built-in async REPL — `await` works at the top level:
4646+4747+```sh
4848+uv run python -m asyncio
4949+```
5050+5151+```python
5252+>>> from rockbox_sdk import RockboxClient, PlaybackStatus
5353+>>> client = RockboxClient(host="localhost", port=6062)
5454+>>> await client.playback.status()
5555+<PlaybackStatus.PLAYING: 1>
5656+>>> track = await client.playback.current_track()
5757+>>> track.title, track.artist
5858+('Money', 'Pink Floyd')
5959+>>> await client.sound.get_volume()
6060+VolumeInfo(volume=-12, min=-74, max=6)
6161+>>> await client.library.search("daft punk")
6262+>>> await client.aclose()
6363+```
6464+6565+You can also test offline — models, enums, and the builder don't need a server:
6666+6767+```python
6868+>>> from rockbox_sdk import RockboxClient, Track, InsertPosition
6969+>>> Track.model_validate({"title": "Money", "albumArt": "x.jpg"}).album_art
7070+'x.jpg'
7171+>>> RockboxClient.builder().host("nas.local").build()._config.resolve_http_url()
7272+'http://nas.local:6062/graphql'
7373+```
7474+7575+If you'd rather use the plain `python` REPL, wrap each call in `asyncio.run(...)`:
7676+7777+```python
7878+>>> import asyncio
7979+>>> from rockbox_sdk import RockboxClient
8080+>>> client = RockboxClient()
8181+>>> asyncio.run(client.playback.status())
8282+```
8383+8484+The async REPL is much nicer — subscriptions (`await client.connect()`) also keep
8585+firing in the background between prompts.
8686+8787+## Configure
8888+8989+```python
9090+from rockbox_sdk import RockboxClient
9191+9292+# Direct kwargs
9393+client = RockboxClient(host="192.168.1.42", port=6062)
9494+9595+# Or fluent builder
9696+client = (
9797+ RockboxClient.builder()
9898+ .host("nas.local")
9999+ .port(6062)
100100+ .timeout(15)
101101+ .build()
102102+)
103103+104104+# Or full URL override
105105+client = RockboxClient(
106106+ http_url="http://nas.local:6062/graphql",
107107+ ws_url="ws://nas.local:6062/graphql",
108108+)
109109+```
110110+111111+Always call `await client.aclose()` when you're done — or use it as an
112112+async context manager:
113113+114114+```python
115115+async with RockboxClient() as client:
116116+ ...
117117+```
118118+119119+## Domains
120120+121121+| Namespace | What it does |
122122+| -------------------------- | ------------------------------------------------------ |
123123+| `client.playback` | Transport (`play`/`pause`/`seek`), play helpers |
124124+| `client.library` | Albums, artists, tracks, search, likes, scan |
125125+| `client.playlist` | The active queue (insert/remove/shuffle/start) |
126126+| `client.saved_playlists` | Persistent playlists & folders |
127127+| `client.smart_playlists` | Rule-based playlists & listening stats |
128128+| `client.sound` | Volume control |
129129+| `client.settings` | Global EQ / replaygain / crossfade / shuffle / … |
130130+| `client.system` | Version, runtime info |
131131+| `client.browse` | Filesystem & UPnP browser |
132132+| `client.devices` | Cast / source device discovery |
133133+| `client.bluetooth` | Bluetooth pairing & scanning (Linux only) |
134134+135135+## Real-time events
136136+137137+```python
138138+from rockbox_sdk import RockboxClient, TRACK_CHANGED, STATUS_CHANGED
139139+140140+async with RockboxClient() as client:
141141+ await client.connect() # opens the WebSocket
142142+143143+ @client.on(TRACK_CHANGED)
144144+ async def on_track(track):
145145+ print(f"▶ {track.title} — {track.artist}")
146146+147147+ @client.on(STATUS_CHANGED)
148148+ def on_status(raw_status):
149149+ print(f"◐ status = {raw_status}")
150150+151151+ await asyncio.Event().wait() # run forever
152152+```
153153+154154+Convenience wrappers exist (`client.on_track_changed(...)`,
155155+`client.on_status_changed(...)`, `client.on_playlist_changed(...)`).
156156+157157+## Plugins
158158+159159+A plugin is anything matching the `RockboxPlugin` protocol — a name, version,
160160+`install(context)`, and optionally `uninstall()`:
161161+162162+```python
163163+from rockbox_sdk import RockboxClient, PlaybackStatus, RockboxPlugin
164164+165165+class SleepTimer:
166166+ name = "sleep-timer"
167167+ version = "1.0.0"
168168+ description = "Stop playback after N minutes"
169169+170170+ def __init__(self, minutes: int) -> None:
171171+ self.minutes = minutes
172172+ self._task: asyncio.Task | None = None
173173+174174+ def install(self, ctx):
175175+ async def fire():
176176+ await asyncio.sleep(self.minutes * 60)
177177+ await ctx.query("mutation { hardStop }")
178178+179179+ self._task = asyncio.create_task(fire())
180180+181181+ @ctx.events.on("status:changed")
182182+ def cancel_on_stop(status: int):
183183+ if status == PlaybackStatus.STOPPED and self._task:
184184+ self._task.cancel()
185185+186186+ def uninstall(self):
187187+ if self._task:
188188+ self._task.cancel()
189189+190190+async with RockboxClient() as client:
191191+ await client.connect()
192192+ await client.use(SleepTimer(30))
193193+```
194194+195195+## Raw GraphQL escape hatch
196196+197197+```python
198198+data = await client.query(
199199+ "query Volume { volume { volume min max } }"
200200+)
201201+```
202202+203203+## Examples
204204+205205+See `examples/` for runnable scripts mirroring the TypeScript SDK examples:
206206+207207+- `01-basic-playback.py`
208208+- `02-now-playing.py`
209209+- `03-library-search.py`
210210+- `04-queue-management.py`
211211+- `05-volume-control.py`
212212+- `06-plugin-sleep-timer.py`
213213+214214+Run with:
215215+216216+```sh
217217+uv run python examples/01_basic_playback.py
218218+```
219219+220220+---
221221+222222+## License
223223+224224+MIT License. See [LICENSE](./LICENSE) for details.
+40
sdk/python/examples/01_basic_playback.py
···11+"""01 — Basic playback.
22+33+Inspect the current track, then either pause or resume based on state.
44+Idempotent: run twice and it toggles between Playing and Paused.
55+66+ uv run python examples/01_basic_playback.py
77+"""
88+99+from __future__ import annotations
1010+1111+import asyncio
1212+1313+from _client import create_client, fmt_time # type: ignore[import-not-found]
1414+1515+from rockbox_sdk import PlaybackStatus
1616+1717+1818+async def main() -> None:
1919+ async with create_client() as client:
2020+ status = await client.playback.status()
2121+ print(f"Status: {status.name}")
2222+2323+ track = await client.playback.current_track()
2424+ if track:
2525+ pct = round((track.elapsed / track.length) * 100) if track.length else 0
2626+ print(f"Now: {track.title} — {track.artist}")
2727+ print(f" {fmt_time(track.elapsed)} / {fmt_time(track.length)} ({pct}%)")
2828+ else:
2929+ print("Nothing is playing.")
3030+3131+ if status == PlaybackStatus.PLAYING:
3232+ await client.playback.pause()
3333+ print("→ paused")
3434+ elif status == PlaybackStatus.PAUSED:
3535+ await client.playback.resume()
3636+ print("→ resumed")
3737+3838+3939+if __name__ == "__main__":
4040+ asyncio.run(main())
···11+"""04 — Queue management.
22+33+Show the current queue, then insert tracks from the first album in the library
44+at the end of the queue.
55+66+ uv run python examples/04_queue_management.py
77+"""
88+99+from __future__ import annotations
1010+1111+import asyncio
1212+1313+from _client import create_client # type: ignore[import-not-found]
1414+1515+from rockbox_sdk import InsertPosition
1616+1717+1818+async def main() -> None:
1919+ async with create_client() as client:
2020+ queue = await client.playlist.current()
2121+ print(f"Queue has {queue.amount} tracks (index {queue.index}):")
2222+ for i, t in enumerate(queue.tracks[:5]):
2323+ marker = "▶" if i == queue.index else " "
2424+ print(f" {marker} {i:>3} {t.title} — {t.artist}")
2525+ if queue.amount > 5:
2626+ print(f" … and {queue.amount - 5} more")
2727+2828+ albums = await client.library.albums()
2929+ if albums:
3030+ album = albums[0]
3131+ print(f"\nAppending album: {album.title} — {album.artist}")
3232+ await client.playlist.insert_album(album.id, InsertPosition.LAST)
3333+ print("→ done")
3434+3535+3636+if __name__ == "__main__":
3737+ asyncio.run(main())
+34
sdk/python/examples/05_volume_control.py
···11+"""05 — Volume control.
22+33+Read current volume (with min/max range) and bump it up by one step.
44+55+ uv run python examples/05_volume_control.py # show + step up
66+ uv run python examples/05_volume_control.py -3 # step down 3
77+"""
88+99+from __future__ import annotations
1010+1111+import asyncio
1212+import sys
1313+1414+from _client import create_client # type: ignore[import-not-found]
1515+1616+1717+async def main(delta: int) -> None:
1818+ async with create_client() as client:
1919+ before = await client.sound.get_volume()
2020+ rng = before.max - before.min
2121+ filled = round(((before.volume - before.min) / rng) * 20) if rng > 0 else 0
2222+ bar = "█" * filled + "░" * max(0, 20 - filled)
2323+2424+ print(f"Volume: {before.volume} dB (range {before.min} … {before.max})")
2525+ print(f" {bar}")
2626+2727+ after = await client.sound.adjust_volume(delta)
2828+ sign = f"+{delta}" if delta >= 0 else str(delta)
2929+ print(f"\nAdjusted by {sign} → {after} dB")
3030+3131+3232+if __name__ == "__main__":
3333+ delta = int(sys.argv[1]) if len(sys.argv) > 1 else 1
3434+ asyncio.run(main(delta))
+71
sdk/python/examples/06_plugin_sleep_timer.py
···11+"""06 — Plugin: sleep timer.
22+33+Stops playback after N minutes. If the user stops playback manually before the
44+timer fires, the plugin cancels itself.
55+66+ uv run python examples/06_plugin_sleep_timer.py # default 30 min
77+ uv run python examples/06_plugin_sleep_timer.py 5 # 5 minutes
88+"""
99+1010+from __future__ import annotations
1111+1212+import asyncio
1313+import contextlib
1414+import sys
1515+from datetime import datetime, timedelta
1616+1717+from _client import create_client # type: ignore[import-not-found]
1818+1919+from rockbox_sdk import PlaybackStatus, PluginContext
2020+2121+2222+class SleepTimer:
2323+ name = "sleep-timer"
2424+ version = "1.0.0"
2525+2626+ def __init__(self, minutes: int) -> None:
2727+ self.minutes = minutes
2828+ self.description = f"Stop playback after {minutes} minute(s)"
2929+ self._task: asyncio.Task[None] | None = None
3030+3131+ def install(self, ctx: PluginContext) -> None:
3232+ fire_at = datetime.now() + timedelta(minutes=self.minutes)
3333+ print(f"💤 Sleep timer armed — will stop playback at {fire_at:%H:%M:%S}")
3434+3535+ async def fire() -> None:
3636+ try:
3737+ await asyncio.sleep(self.minutes * 60)
3838+ except asyncio.CancelledError:
3939+ return
4040+ print("💤 Time's up — stopping playback.")
4141+ await ctx.query("mutation { hardStop }")
4242+4343+ self._task = asyncio.create_task(fire())
4444+4545+ @ctx.events.on("status:changed")
4646+ def cancel_on_stop(status: int) -> None:
4747+ if status == PlaybackStatus.STOPPED and self._task and not self._task.done():
4848+ self._task.cancel()
4949+ print("💤 Playback stopped manually — sleep timer cancelled.")
5050+5151+ def uninstall(self) -> None:
5252+ if self._task and not self._task.done():
5353+ self._task.cancel()
5454+5555+5656+async def main(minutes: int) -> None:
5757+ async with create_client() as client:
5858+ await client.connect()
5959+ await client.use(SleepTimer(minutes))
6060+6161+ print("Plugin installed. Press Ctrl+C to cancel and exit.")
6262+ with contextlib.suppress(asyncio.CancelledError):
6363+ await asyncio.Event().wait()
6464+6565+6666+if __name__ == "__main__":
6767+ minutes = int(sys.argv[1]) if len(sys.argv) > 1 else 30
6868+ try:
6969+ asyncio.run(main(minutes))
7070+ except KeyboardInterrupt:
7171+ print("\nbye")
+23
sdk/python/examples/_client.py
···11+"""Shared client factory used by every example.
22+33+Override the host/port via env: ``ROCKBOX_HOST``, ``ROCKBOX_PORT``.
44+"""
55+66+from __future__ import annotations
77+88+import os
99+1010+from rockbox_sdk import RockboxClient
1111+1212+1313+def create_client() -> RockboxClient:
1414+ return RockboxClient(
1515+ host=os.environ.get("ROCKBOX_HOST", "localhost"),
1616+ port=int(os.environ.get("ROCKBOX_PORT", "6062")),
1717+ )
1818+1919+2020+def fmt_time(ms: int) -> str:
2121+ """Format milliseconds as ``M:SS``."""
2222+ total = max(0, ms // 1000)
2323+ return f"{total // 60}:{total % 60:02d}"
···11+"""Reusable GraphQL fragments. Inlined verbatim into queries — keep in sync with the schema."""
22+33+TRACK_FIELDS = """
44+fragment TrackFields on Track {
55+ id title artist album genre disc trackString yearString
66+ composer comment albumArtist grouping
77+ discnum tracknum layer year bitrate frequency
88+ filesize length elapsed path
99+ albumId artistId genreId albumArt
1010+}
1111+"""
1212+1313+ALBUM_FIELDS = """
1414+fragment AlbumFields on Album {
1515+ id title artist year yearString albumArt md5 artistId copyrightMessage
1616+}
1717+"""
1818+1919+ARTIST_FIELDS = """
2020+fragment ArtistFields on Artist {
2121+ id name bio image
2222+}
2323+"""
2424+2525+BLUETOOTH_DEVICE_FIELDS = """
2626+fragment BluetoothDeviceFields on BluetoothDevice {
2727+ address name paired trusted connected rssi
2828+}
2929+"""
···11+MIT License
22+33+Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app>
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+464
sdk/ruby/README.md
···11+# rockbox
22+33+Ruby SDK for [Rockbox](https://www.rockbox.org) — a builder-friendly, block-friendly GraphQL client with real-time event subscriptions and a plugin system.
44+55+```ruby
66+require "rockbox"
77+88+client = Rockbox::Client.build do |c|
99+ c.host = "localhost"
1010+ c.port = 6062
1111+end
1212+1313+client.on(:track_changed) { |t| puts "▶ #{t.title} — #{t.artist}" }
1414+client.connect
1515+1616+results = client.library.search("dark side")
1717+client.playback.play_album(results.albums.first.id, shuffle: true)
1818+```
1919+2020+---
2121+2222+## Table of contents
2323+2424+- [Installation](#installation)
2525+- [Quick start](#quick-start)
2626+- [Configuration](#configuration)
2727+- [API reference](#api-reference)
2828+ - [Playback](#playback)
2929+ - [Library](#library)
3030+ - [Playlist (queue)](#playlist-queue)
3131+ - [Saved playlists](#saved-playlists)
3232+ - [Smart playlists](#smart-playlists)
3333+ - [Sound](#sound)
3434+ - [Settings](#settings)
3535+ - [System](#system)
3636+ - [Browse (filesystem)](#browse-filesystem)
3737+ - [Devices](#devices)
3838+ - [Bluetooth](#bluetooth)
3939+- [Real-time events](#real-time-events)
4040+- [Plugin system](#plugin-system)
4141+- [Error handling](#error-handling)
4242+- [Raw GraphQL queries](#raw-graphql-queries)
4343+4444+---
4545+4646+## Installation
4747+4848+```sh
4949+gem install rockbox
5050+```
5151+5252+Or with Bundler:
5353+5454+```ruby
5555+# Gemfile
5656+gem "rockbox"
5757+```
5858+5959+`rockboxd` must be running and reachable. The SDK targets **Ruby 3.0+** and connects to `http://localhost:6062/graphql` by default.
6060+6161+---
6262+6363+## Quick start
6464+6565+```ruby
6666+require "rockbox"
6767+6868+client = Rockbox::Client.new
6969+7070+# Optional — start WebSocket subscriptions for real-time events.
7171+client.connect
7272+7373+# What's playing?
7474+if (track = client.playback.current_track)
7575+ puts "Now playing: #{track.title} — #{track.artist}"
7676+end
7777+7878+# Search the library.
7979+results = client.library.search("dark side")
8080+puts "Found #{results.albums.size} albums and #{results.tracks.size} tracks"
8181+8282+# Play an album with shuffle.
8383+client.playback.play_album(results.albums.first.id, shuffle: true)
8484+8585+# React to track changes.
8686+client.on(:track_changed) do |track|
8787+ puts "▶ #{track.title} by #{track.artist}"
8888+end
8989+9090+# Tear down when done.
9191+client.disconnect
9292+```
9393+9494+---
9595+9696+## Configuration
9797+9898+Three equivalent ways to configure the client. Pick the one that fits your style.
9999+100100+```ruby
101101+# 1. Defaults — localhost:6062.
102102+client = Rockbox::Client.new
103103+104104+# 2. Keyword arguments.
105105+client = Rockbox::Client.new(host: "192.168.1.42", port: 6062)
106106+107107+# 3. Builder block (great for application initializers).
108108+client = Rockbox::Client.build do |c|
109109+ c.host = "192.168.1.42"
110110+ c.port = 6062
111111+end
112112+113113+# 4. Fully custom URLs (useful behind a reverse proxy).
114114+client = Rockbox::Client.new(
115115+ http_url: "https://music.home/graphql",
116116+ ws_url: "wss://music.home/graphql"
117117+)
118118+119119+# Top-level shorthand.
120120+client = Rockbox.new(host: "localhost")
121121+```
122122+123123+| Option | Type | Default | Description |
124124+|----------------|----------|---------------------------------|--------------------------------------|
125125+| `host` | String | `"localhost"` | Hostname or IP of rockboxd |
126126+| `port` | Integer | `6062` | GraphQL port |
127127+| `http_url` | String | `http://{host}:{port}/graphql` | Override the full HTTP URL |
128128+| `ws_url` | String | `ws://{host}:{port}/graphql` | Override the full WebSocket URL |
129129+| `open_timeout` | Integer | `5` | HTTP connect timeout (seconds) |
130130+| `read_timeout` | Integer | `30` | HTTP read timeout (seconds) |
131131+132132+---
133133+134134+## API reference
135135+136136+The client exposes a domain namespace per concern (Mopidy-style). Every method returns idiomatic Ruby data — `Struct` instances with `snake_case` accessors.
137137+138138+### Playback
139139+140140+```ruby
141141+client.playback
142142+```
143143+144144+```ruby
145145+# Status
146146+client.playback.status # => Integer (Rockbox::PlaybackStatus::PLAYING, etc.)
147147+client.playback.status_name # => :playing | :paused | :stopped | :unknown
148148+149149+# Current/next track
150150+track = client.playback.current_track # => Rockbox::Track
151151+client.playback.next_track
152152+client.playback.file_position
153153+154154+# Transport controls
155155+client.playback.play(elapsed: 0, offset: 0)
156156+client.playback.pause
157157+client.playback.resume
158158+client.playback.next!
159159+client.playback.previous!
160160+client.playback.seek(60_000) # ms
161161+client.playback.stop
162162+client.playback.flush_and_reload
163163+164164+# One-shot play helpers
165165+client.playback.play_track("/Music/song.mp3")
166166+client.playback.play_album(album_id, shuffle: true)
167167+client.playback.play_artist(artist_id)
168168+client.playback.play_playlist(playlist_id, shuffle: true, position: 0)
169169+client.playback.play_directory("/Music/Pink Floyd", recurse: true)
170170+client.playback.play_liked_tracks(shuffle: true)
171171+client.playback.play_all_tracks
172172+```
173173+174174+### Library
175175+176176+```ruby
177177+# Albums
178178+client.library.albums # => Array<Rockbox::Album>
179179+client.library.album(id) # => Rockbox::Album | nil
180180+client.library.liked_albums
181181+client.library.like_album(id)
182182+client.library.unlike_album(id)
183183+184184+# Artists
185185+client.library.artists
186186+client.library.artist(id)
187187+188188+# Tracks
189189+client.library.tracks
190190+client.library.track(id)
191191+client.library.liked_tracks
192192+client.library.like_track(id)
193193+client.library.unlike_track(id)
194194+195195+# Search
196196+results = client.library.search("daft punk")
197197+results.artists # => Array<Rockbox::Artist>
198198+results.albums # => Array<Rockbox::Album>
199199+results.tracks # => Array<Rockbox::Track>
200200+results.liked_tracks
201201+results.liked_albums
202202+203203+# Library scan
204204+client.library.scan
205205+```
206206+207207+### Playlist (queue)
208208+209209+```ruby
210210+playlist = client.playlist.current
211211+playlist.amount # 42
212212+playlist.index # currently playing index
213213+playlist.tracks # Array<Rockbox::Track>
214214+215215+client.playlist.amount # convenience for playlist.current.amount
216216+217217+# Inserts (paths or track IDs)
218218+client.playlist.insert_tracks(["/Music/a.mp3", "/Music/b.mp3"],
219219+ position: Rockbox::InsertPosition::NEXT)
220220+client.playlist.insert_directory("/Music/Pink Floyd", position: Rockbox::InsertPosition::LAST)
221221+client.playlist.insert_album(album_id)
222222+223223+# Mutations
224224+client.playlist.remove_track(3)
225225+client.playlist.clear
226226+client.playlist.shuffle
227227+228228+# Create + start a temporary playlist
229229+client.playlist.create("Tonight", ["/Music/a.mp3", "/Music/b.mp3"])
230230+client.playlist.start(start_index: 0)
231231+client.playlist.resume
232232+```
233233+234234+`Rockbox::InsertPosition` constants: `NEXT`, `AFTER_CURRENT`, `LAST`, `FIRST`.
235235+236236+### Saved playlists
237237+238238+```ruby
239239+client.saved_playlists.list # => Array<Rockbox::SavedPlaylist>
240240+client.saved_playlists.list(folder_id: "f_1")
241241+client.saved_playlists.get("pl_42")
242242+client.saved_playlists.track_ids("pl_42")
243243+244244+# Builder block (any field is optional)
245245+playlist = client.saved_playlists.create(name: "Late nights") do |p|
246246+ p.description = "After-dark vibes"
247247+ p.image = "https://…/cover.png"
248248+ p.track_ids = ["abc", "def"]
249249+end
250250+251251+# Or pass kwargs directly
252252+client.saved_playlists.update("pl_42", name: "Renamed", description: "…")
253253+client.saved_playlists.add_tracks("pl_42", ["abc", "def"])
254254+client.saved_playlists.remove_track("pl_42", "abc")
255255+client.saved_playlists.delete("pl_42")
256256+client.saved_playlists.play("pl_42")
257257+258258+# Folders
259259+client.saved_playlists.folders
260260+client.saved_playlists.create_folder("Workout")
261261+client.saved_playlists.delete_folder("f_1")
262262+```
263263+264264+### Smart playlists
265265+266266+```ruby
267267+client.smart_playlists.list
268268+client.smart_playlists.get(id)
269269+client.smart_playlists.track_ids(id)
270270+client.smart_playlists.create(name: "Heavy hitters", rules: rules_json)
271271+client.smart_playlists.update(id, name: "…", rules: new_rules)
272272+client.smart_playlists.delete(id)
273273+client.smart_playlists.play(id)
274274+275275+# Listening stats
276276+client.smart_playlists.track_stats(track_id)
277277+client.smart_playlists.record_played(track_id)
278278+client.smart_playlists.record_skipped(track_id)
279279+```
280280+281281+### Sound
282282+283283+```ruby
284284+info = client.sound.volume # => Rockbox::VolumeInfo(volume:, min:, max:)
285285+client.sound.adjust(3) # +3 steps
286286+client.sound.up # +1 step
287287+client.sound.down # -1 step
288288+```
289289+290290+### Settings
291291+292292+```ruby
293293+settings = client.settings.get # => Rockbox::UserSettings (Struct)
294294+settings.volume # -20
295295+settings.shuffle # false
296296+297297+# Save with a builder block — only the fields you set are sent.
298298+client.settings.save do |s|
299299+ s.volume = -10
300300+ s.bass = 4
301301+ s.shuffle = true
302302+end
303303+304304+# Or with a plain Hash.
305305+client.settings.save(volume: -10, shuffle: true)
306306+```
307307+308308+### System
309309+310310+```ruby
311311+client.system.version # "Rockbox v…"
312312+client.system.status # Rockbox::SystemStatus
313313+```
314314+315315+### Browse (filesystem)
316316+317317+```ruby
318318+client.browse.entries("/Music") # => Array<Rockbox::Entry>
319319+client.browse.directories("/Music") # only directories
320320+client.browse.files("/Music/Pink Floyd") # only files
321321+322322+entry = client.browse.entries.first
323323+Rockbox.directory?(entry) # => true/false
324324+```
325325+326326+### Devices
327327+328328+```ruby
329329+client.devices.list # => Array<Rockbox::Device>
330330+client.devices.get(id)
331331+client.devices.connect(id)
332332+client.devices.disconnect(id)
333333+```
334334+335335+### Bluetooth
336336+337337+> Linux only. macOS/Windows builds will return errors.
338338+339339+```ruby
340340+client.bluetooth.devices
341341+client.bluetooth.scan(timeout: 5)
342342+client.bluetooth.connect("AA:BB:CC:DD:EE:FF")
343343+client.bluetooth.disconnect("AA:BB:CC:DD:EE:FF")
344344+```
345345+346346+---
347347+348348+## Real-time events
349349+350350+Call `#connect` to open the WebSocket and start receiving events. The client speaks the GraphQL `graphql-transport-ws` subprotocol.
351351+352352+```ruby
353353+client = Rockbox::Client.new
354354+client.connect
355355+356356+client.on(:track_changed) { |track| puts "▶ #{track.title}" }
357357+client.on(:status_changed) { |status| puts Rockbox::PlaybackStatus.name(status) }
358358+client.on(:playlist_changed) { |playlist| puts "queue: #{playlist.amount} tracks" }
359359+client.on(:ws_open) { puts "connected" }
360360+client.on(:ws_close) { puts "disconnected" }
361361+client.on(:ws_error) { |err| warn err.message }
362362+363363+# Once-only listener
364364+client.once(:track_changed) { |t| puts "first track: #{t.title}" }
365365+366366+# Remove a specific listener with the same Proc
367367+listener = ->(t) { puts t.title }
368368+client.on(:track_changed, &listener)
369369+client.off(:track_changed, listener)
370370+371371+# Remove all listeners for an event (or all events)
372372+client.remove_all_listeners(:track_changed)
373373+client.remove_all_listeners
374374+375375+# Tear down
376376+client.disconnect
377377+```
378378+379379+| Event | Payload |
380380+|--------------------|--------------------------------------|
381381+| `:track_changed` | `Rockbox::Track` |
382382+| `:status_changed` | `Integer` (`Rockbox::PlaybackStatus`)|
383383+| `:playlist_changed`| `Rockbox::Playlist` |
384384+| `:ws_open` | `nil` |
385385+| `:ws_close` | `nil` |
386386+| `:ws_error` | `Exception` |
387387+388388+---
389389+390390+## Plugin system
391391+392392+Plugins are duck-typed objects with `#name`, `#version`, and `#install(context)`. Inherit from `Rockbox::Plugin` for sane defaults.
393393+394394+```ruby
395395+class ConsoleScrobbler < Rockbox::Plugin
396396+ def name; "console-scrobbler" end
397397+ def version; "0.1.0" end
398398+ def description; "Logs every track change" end
399399+400400+ def install(ctx)
401401+ ctx.events.on(:track_changed) do |track|
402402+ puts "♪ #{track.artist} — #{track.title}"
403403+ end
404404+ end
405405+406406+ def uninstall
407407+ # cleanup
408408+ end
409409+end
410410+411411+client.use(ConsoleScrobbler.new)
412412+client.installed_plugins # => [<ConsoleScrobbler ...>]
413413+client.unuse("console-scrobbler")
414414+```
415415+416416+The `PluginContext` exposes:
417417+418418+- `ctx.query.call(gql, variables = nil)` — issue raw GraphQL operations
419419+- `ctx.events` — the same `Rockbox::EventEmitter` used by the client
420420+421421+---
422422+423423+## Error handling
424424+425425+```ruby
426426+begin
427427+ client.library.album("does-not-exist")
428428+rescue Rockbox::GraphQLError => e
429429+ e.errors.each { |err| warn err[:message] }
430430+rescue Rockbox::NetworkError => e
431431+ warn "rockboxd unreachable: #{e.message}"
432432+end
433433+```
434434+435435+| Class | Raised when… |
436436+|---------------------------|----------------------------------------------|
437437+| `Rockbox::Error` | Base class for every SDK error. |
438438+| `Rockbox::NetworkError` | rockboxd is unreachable / non-2xx response. |
439439+| `Rockbox::GraphQLError` | rockboxd returns a GraphQL `errors` payload. |
440440+441441+---
442442+443443+## Raw GraphQL queries
444444+445445+For operations the SDK doesn't yet wrap, use `#query` directly. Variables are camelized on the way out, response keys are snakeized on the way in.
446446+447447+```ruby
448448+data = client.query(
449449+ "query LikedSongs { likedTracks { id title } }"
450450+)
451451+data[:liked_tracks].each { |t| puts t[:title] }
452452+453453+data = client.query(<<~GQL, { id: "abc" })
454454+ query Track($id: String!) {
455455+ track(id: $id) { id title artist }
456456+ }
457457+GQL
458458+```
459459+460460+---
461461+462462+## License
463463+464464+MIT License. See [LICENSE](./LICENSE) for details.
+34
sdk/ruby/bin/console
···11+#!/usr/bin/env ruby
22+# frozen_string_literal: true
33+#
44+# Drop into an IRB session with the SDK pre-loaded.
55+#
66+# ./bin/console
77+#
88+# Environment variables:
99+# ROCKBOX_HOST hostname or IP of rockboxd (default: localhost)
1010+# ROCKBOX_PORT GraphQL port (default: 6062)
1111+# ROCKBOX_AUTOCONNECT=1 open the WebSocket immediately
1212+1313+$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
1414+require "rockbox"
1515+require "irb"
1616+1717+host = ENV.fetch("ROCKBOX_HOST", "localhost")
1818+port = ENV.fetch("ROCKBOX_PORT", "6062").to_i
1919+2020+# `client` is auto-available in IRB.
2121+client = Rockbox::Client.new(host: host, port: port)
2222+2323+if ENV["ROCKBOX_AUTOCONNECT"] == "1"
2424+ client.on(:track_changed) { |t| puts "▶ #{t.title} — #{t.artist}" }
2525+ client.connect
2626+end
2727+2828+puts "Rockbox SDK v#{Rockbox::VERSION} — connected to #{client.configuration.resolved_http_url}"
2929+puts " client.playback.status_name"
3030+puts " client.library.search(\"…\")"
3131+puts " client.connect # start WebSocket subscriptions"
3232+puts
3333+3434+IRB.start(__FILE__)
+26
sdk/ruby/examples/plugin.rb
···11+# frozen_string_literal: true
22+#
33+# Plugin example — listens to track-change events and prints a one-line log.
44+#
55+# ruby -Ilib examples/plugin.rb
66+77+require "rockbox"
88+99+class ConsoleScrobbler < Rockbox::Plugin
1010+ def name; "console-scrobbler" end
1111+ def version; "0.1.0" end
1212+ def description; "Logs every track change to stdout" end
1313+1414+ def install(ctx)
1515+ ctx.events.on(:track_changed) do |track|
1616+ puts "♪ #{Time.now.iso8601} #{track.artist} — #{track.title}"
1717+ end
1818+ end
1919+end
2020+2121+client = Rockbox::Client.new
2222+client.use(ConsoleScrobbler.new)
2323+client.connect
2424+2525+trap("INT") { client.disconnect; exit }
2626+sleep
+32
sdk/ruby/examples/quickstart.rb
···11+# frozen_string_literal: true
22+#
33+# Run with: ruby -Ilib examples/quickstart.rb
44+#
55+# Make sure rockboxd is reachable on http://localhost:6062.
66+77+require "rockbox"
88+99+# Configure with the builder DSL.
1010+client = Rockbox::Client.build do |c|
1111+ c.host = ENV.fetch("ROCKBOX_HOST", "localhost")
1212+ c.port = ENV.fetch("ROCKBOX_PORT", "6062").to_i
1313+end
1414+1515+puts "Rockbox version: #{client.system.version}"
1616+1717+if (track = client.playback.current_track)
1818+ puts "Now playing: #{track.title} — #{track.artist}"
1919+ puts " album: #{track.album}, length: #{track.length}ms, elapsed: #{track.elapsed}ms"
2020+else
2121+ puts "Nothing playing."
2222+end
2323+2424+# Search the library.
2525+results = client.library.search("dark side")
2626+puts "Found #{results.albums.size} albums and #{results.tracks.size} tracks"
2727+2828+# Play the first matching album with shuffle on.
2929+if (first = results.albums.first)
3030+ client.playback.play_album(first.id, shuffle: true)
3131+ puts "▶ #{first.title} by #{first.artist}"
3232+end
+32
sdk/ruby/examples/subscribe.rb
···11+# frozen_string_literal: true
22+#
33+# Subscribe to real-time playback events. Run with:
44+# ruby -Ilib examples/subscribe.rb
55+66+require "rockbox"
77+88+client = Rockbox::Client.new
99+1010+client.on(:track_changed) do |track|
1111+ puts "▶ #{track.title} — #{track.artist}"
1212+end
1313+1414+client.on(:status_changed) do |status|
1515+ puts "status -> #{Rockbox::PlaybackStatus.name(status)}"
1616+end
1717+1818+client.on(:playlist_changed) do |playlist|
1919+ puts "playlist now has #{playlist.amount} tracks (index=#{playlist.index})"
2020+end
2121+2222+client.on(:ws_error) { |err| warn "ws error: #{err.message}" }
2323+2424+client.connect
2525+puts "Listening for events. Ctrl+C to exit."
2626+2727+trap("INT") do
2828+ client.disconnect
2929+ exit
3030+end
3131+3232+sleep
···11+MIT License
22+33+Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app>
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+6
sdk/typescript/README.md
···957957const dirs = entries.filter(isDirectory);
958958const files = entries.filter((e) => !isDirectory(e));
959959```
960960+961961+---
962962+963963+## License
964964+965965+MIT License. See [LICENSE](./LICENSE) for details.