Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

add more SDK

+17210 -3
+3
.gitignore
··· 340 340 .vscode 341 341 # /utils/time-sync/ 342 342 /utils/time-sync/time-sync 343 + 344 + *.dump 345 + .claude/*.lock
+3 -3
cli/src/lib.rs
··· 35 35 let mut child = Command::new("sh") 36 36 .arg("-c") 37 37 .arg(cmd) 38 - .stdin(Stdio::inherit()) 39 - .stdout(Stdio::inherit()) 40 - .stderr(Stdio::inherit()) 38 + .stdin(Stdio::null()) 39 + .stdout(Stdio::null()) 40 + .stderr(Stdio::null()) 41 41 .spawn()?; 42 42 let status = child.wait()?; 43 43
+9
sdk/clojure/.gitignore
··· 1 + .cpcache/ 2 + .clj-kondo/ 3 + .lsp/ 4 + .nrepl-port 5 + target/ 6 + pom.xml 7 + pom.xml.asc 8 + *.jar 9 + .calva/
+21
sdk/clojure/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app> 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+691
sdk/clojure/README.md
··· 1 + # rockbox-clj 2 + 3 + Idiomatic Clojure SDK for [Rockbox](https://www.rockbox.org) — a thin, 4 + zero-dependency-heavy wrapper around rockboxd's GraphQL API with real-time 5 + WebSocket subscriptions and a tiny plugin system. 6 + 7 + * **Pipe-friendly.** Every function takes the client as its first argument. 8 + Action functions return the client so they compose with `->`. 9 + * **Builder-friendly.** `with-host`, `with-port`, `with-timeout`, 10 + `with-headers`, `with-http-url`, `with-ws-url` — all pure, all chainable. 11 + * **Clojure-friendly.** Plain maps with kebab-case keys both in and out; 12 + enums exposed as keywords; events surface as callbacks _or_ `core.async` 13 + channels; plugins are plain maps you `assoc` into shape. 14 + * **Light dependencies.** Only `org.clojure/data.json` and `core.async` — 15 + HTTP and WebSockets ride on JDK 11+'s built-in `java.net.http`. 16 + 17 + --- 18 + 19 + ## Table of contents 20 + 21 + - [Installation](#installation) 22 + - [Quick start](#quick-start) 23 + - [Configuration](#configuration) 24 + - [API reference](#api-reference) 25 + - [Playback](#playback) 26 + - [Library](#library) 27 + - [Playlist (queue)](#playlist-queue) 28 + - [Saved playlists](#saved-playlists) 29 + - [Smart playlists](#smart-playlists) 30 + - [Sound](#sound) 31 + - [Settings](#settings) 32 + - [System](#system) 33 + - [Browse (filesystem)](#browse-filesystem) 34 + - [Devices](#devices) 35 + - [Bluetooth](#bluetooth) 36 + - [Real-time events](#real-time-events) 37 + - [Plugin system](#plugin-system) 38 + - [Error handling](#error-handling) 39 + - [Raw GraphQL queries](#raw-graphql-queries) 40 + - [Types reference](#types-reference) 41 + 42 + --- 43 + 44 + ## Installation 45 + 46 + `deps.edn`: 47 + 48 + ```clojure 49 + {:deps {com.rockbox/rockbox-clj {:git/url "https://github.com/tsirysndr/rockbox-zig" 50 + :git/sha "..." 51 + :deps/root "sdk/clojure"}}} 52 + ``` 53 + 54 + Or pin via local path while developing: 55 + 56 + ```clojure 57 + {:deps {com.rockbox/rockbox-clj {:local/root "/path/to/rockbox-zig/sdk/clojure"}}} 58 + ``` 59 + 60 + `rockboxd` must be running and reachable. By default the SDK connects to 61 + `http://localhost:6062/graphql`. Start it with: 62 + 63 + ```sh 64 + ./zig/zig-out/bin/rockboxd 65 + ``` 66 + 67 + --- 68 + 69 + ## Quick start 70 + 71 + ```clojure 72 + (require '[rockbox.core :as rb] 73 + '[rockbox.playback :as pb] 74 + '[rockbox.library :as lib]) 75 + 76 + (def client (rb/client)) 77 + 78 + ;; Optional: open the WebSocket so subscribers start receiving events 79 + (rb/connect client) 80 + 81 + ;; What's playing right now? 82 + (when-let [t (pb/current-track client)] 83 + (println "Now playing:" (:title t) "—" (:artist t))) 84 + 85 + ;; Search the library 86 + (let [{:keys [albums tracks]} (lib/search client "dark side")] 87 + (println (count albums) "albums," (count tracks) "tracks")) 88 + 89 + ;; Play an album, shuffled — in one piped chain 90 + (-> client 91 + (pb/play-album "album-id" {:shuffle true})) 92 + 93 + ;; React to track changes 94 + (rb/on client :track-changed 95 + (fn [t] (println "▶" (:title t) "by" (:artist t)))) 96 + 97 + ;; Tear down when done 98 + (rb/disconnect client) 99 + ``` 100 + 101 + --- 102 + 103 + ## Configuration 104 + 105 + ```clojure 106 + (require '[rockbox.core :as rb]) 107 + 108 + ;; Defaults: localhost:6062 109 + (def c (rb/client)) 110 + 111 + ;; Custom host and port 112 + (def c (rb/client {:host "192.168.1.42" :port 6062})) 113 + 114 + ;; Fully custom URLs (e.g. behind a reverse proxy) 115 + (def c (rb/client {:http-url "https://music.home/graphql" 116 + :ws-url "wss://music.home/graphql"})) 117 + 118 + ;; Builder style — every with-* fn returns a new client value 119 + (def c (-> (rb/client) 120 + (rb/with-host "music.home") 121 + (rb/with-port 6062) 122 + (rb/with-timeout 30000) 123 + (rb/with-headers {:x-trace-id "req-123"}))) 124 + ``` 125 + 126 + | Option | Default | Description | 127 + |---------------|-----------------------------------|-------------------------------------| 128 + | `:host` | `"localhost"` | rockboxd hostname / IP | 129 + | `:port` | `6062` | GraphQL HTTP/WS port | 130 + | `:http-url` | `http://{host}:{port}/graphql` | Override the full HTTP URL | 131 + | `:ws-url` | `ws://{host}:{port}/graphql` | Override the full WS URL | 132 + | `:timeout-ms` | `15000` | Per-request timeout | 133 + | `:headers` | `{}` | Extra HTTP headers map | 134 + | `:http-client`| (auto) | Reuse a `java.net.http.HttpClient` | 135 + 136 + --- 137 + 138 + ## API reference 139 + 140 + > Convention: **action functions return the client** so chains compose with 141 + > `->`. **Read functions return data** as plain Clojure maps with kebab-case 142 + > keys. 143 + 144 + ### Playback 145 + 146 + ```clojure 147 + (require '[rockbox.playback :as pb] 148 + '[rockbox.types :as t]) 149 + 150 + ;; Status 151 + (pb/status client) ;=> :playing | :paused | :stopped 152 + (pb/raw-status client) ;=> 0 | 1 | 3 (raw firmware enum) 153 + 154 + ;; Current / next track 155 + (pb/current-track client) ;=> {:title "..." :artist "..." :elapsed 12345 ...} or nil 156 + (pb/next-track client) 157 + (pb/file-position client) 158 + 159 + ;; Transport — pipe-friendly 160 + (-> client 161 + (pb/pause) 162 + (pb/seek 90000) ; jump to 1:30 (ms) 163 + (pb/resume)) 164 + 165 + (pb/play client) 166 + (pb/play client {:elapsed 0 :offset 0}) 167 + (pb/next client) 168 + (pb/previous client) 169 + (pb/stop client) 170 + (pb/flush-and-reload client) 171 + 172 + ;; Single-call play helpers 173 + (pb/play-track client "/Music/Pink Floyd/Wish You Were Here.mp3") 174 + (pb/play-album client "album-id" {:shuffle true}) 175 + (pb/play-album client "album-id" {:position 3}) 176 + (pb/play-artist client "artist-id" {:shuffle true}) 177 + (pb/play-playlist client "playlist-id" {:shuffle true}) 178 + (pb/play-directory client "/Music/Jazz" {:recurse true :shuffle true}) 179 + (pb/play-liked-tracks client {:shuffle true}) 180 + (pb/play-all-tracks client {:shuffle true}) 181 + ``` 182 + 183 + --- 184 + 185 + ### Library 186 + 187 + ```clojure 188 + (require '[rockbox.library :as lib]) 189 + 190 + ;; Albums 191 + (lib/albums client) ;=> vector of album maps with shallow track stubs 192 + (lib/album client "album-id") ;=> album with full track list, or nil 193 + (lib/liked-albums client) 194 + (lib/like-album client "album-id") 195 + (lib/unlike-album client "album-id") 196 + 197 + ;; Artists 198 + (lib/artists client) 199 + (lib/artist client "artist-id") 200 + 201 + ;; Tracks 202 + (lib/tracks client) 203 + (lib/track client "track-id") 204 + (lib/liked-tracks client) 205 + (lib/like-track client "track-id") 206 + (lib/unlike-track client "track-id") 207 + 208 + ;; Search — returns {:artists :albums :tracks :liked-tracks :liked-albums} 209 + (let [{:keys [albums tracks]} (lib/search client "radiohead")] 210 + (println (count albums) "albums," (count tracks) "tracks")) 211 + 212 + ;; Trigger a full library scan 213 + (lib/scan client) 214 + ``` 215 + 216 + --- 217 + 218 + ### Playlist (queue) 219 + 220 + The *playlist* namespace manages the live playback queue. For persistent 221 + named collections use [Saved playlists](#saved-playlists). 222 + 223 + ```clojure 224 + (require '[rockbox.playlist :as q] 225 + '[rockbox.types :as t]) 226 + 227 + ;; Inspect 228 + (q/current client) ;=> {:tracks [...] :amount n :index i ...} 229 + (q/amount client) 230 + 231 + ;; Queue management — every mutation returns the client 232 + (-> client 233 + (q/insert-tracks ["/Music/a.mp3" "/Music/b.mp3"] :next) 234 + (q/insert-album "album-id" :last) 235 + (q/shuffle)) 236 + 237 + (q/insert-directory client "/Music/Ambient" :last) 238 + (q/remove-track client 2) ; remove queue index 2 239 + (q/clear client) 240 + (q/create client "Evening Mix" ["/Music/a.mp3" "/Music/b.mp3"]) 241 + (q/start client {:start-index 0}) 242 + (q/resume client) 243 + ``` 244 + 245 + | `insert-position` keyword | Effect | 246 + |---------------------------|----------------------------------------| 247 + | `:next` | After the currently playing track | 248 + | `:after-current` | After the last manually inserted track | 249 + | `:last` | At the end of the queue | 250 + | `:first` | Replace the entire queue | 251 + 252 + (You can also pass the underlying integer if you prefer.) 253 + 254 + --- 255 + 256 + ### Saved playlists 257 + 258 + ```clojure 259 + (require '[rockbox.saved-playlists :as sp]) 260 + 261 + (sp/list client) ; all 262 + (sp/list client "folder-id") ; in a folder 263 + (sp/get client "playlist-id") 264 + (sp/track-ids client "playlist-id") 265 + 266 + (sp/create client {:name "Late Night Jazz" 267 + :description "Quiet music for working" 268 + :folder-id "folder-id" 269 + :track-ids ["t1" "t2" "t3"]}) 270 + 271 + (sp/update client "playlist-id" {:name "Late Night Jazz (updated)"}) 272 + 273 + (sp/add-tracks client "playlist-id" ["t4" "t5"]) 274 + (sp/remove-track client "playlist-id" "t1") 275 + (sp/play client "playlist-id") 276 + (sp/delete client "playlist-id") 277 + 278 + ;; Folders 279 + (sp/folders client) 280 + (sp/create-folder client "Work") 281 + (sp/delete-folder client "folder-id") 282 + ``` 283 + 284 + --- 285 + 286 + ### Smart playlists 287 + 288 + Smart playlists evaluate a rule set dynamically. The SDK accepts the 289 + `:rules` value as either a JSON string or any Clojure data structure (it 290 + will JSON-encode for you). 291 + 292 + ```clojure 293 + (require '[rockbox.smart-playlists :as smart]) 294 + 295 + (smart/list client) 296 + (smart/get client "smart-id") 297 + (smart/track-ids client "smart-id") ; resolve to matching track ids 298 + 299 + ;; Create — rules as plain Clojure data 300 + (smart/create client 301 + {:name "Recently played" 302 + :rules {:operator "AND" 303 + :rules [{:field "play_count" :op "gt" :value 0} 304 + {:field "last_played" :op "within" :value "30d"}]}}) 305 + 306 + ;; Or as a pre-baked JSON string 307 + (smart/create client {:name "Top 50" :rules "{\"sort\":{...}}"}) 308 + 309 + (smart/update client "smart-id" {:name "Recently played (60d)" 310 + :rules {...}}) 311 + (smart/play client "smart-id") 312 + (smart/delete client "smart-id") 313 + 314 + ;; Listening stats — feeds smart-playlist rules and scrobblers 315 + (smart/track-stats client "track-id") ;=> {:play-count n :skip-count n :last-played t} 316 + (smart/record-played client "track-id") 317 + (smart/record-skipped client "track-id") 318 + ``` 319 + 320 + --- 321 + 322 + ### Sound 323 + 324 + Volume is measured in firmware-defined steps (not absolute dB). The number 325 + of steps per dB varies by hardware target. 326 + 327 + ```clojure 328 + (require '[rockbox.sound :as snd]) 329 + 330 + (snd/volume client) ;=> {:volume v :min m :max M} 331 + (snd/adjust-volume client +3) ; 3 steps up; returns the new raw volume 332 + (snd/volume-up client) ; +1 333 + (snd/volume-down client) ; -1 334 + ``` 335 + 336 + --- 337 + 338 + ### Settings 339 + 340 + ```clojure 341 + (require '[rockbox.settings :as settings]) 342 + 343 + (def s (settings/get client)) 344 + (println :music-dir (:music-dir s) 345 + :volume (:volume s) 346 + :eq-enabled (:eq-enabled s) 347 + :repeat-mode (:repeat-mode s)) 348 + 349 + ;; Partial update — only the keys you pass are written 350 + (settings/save client 351 + {:shuffle true 352 + :repeat-mode 1}) ; or use rockbox.types/repeat-mode 353 + 354 + ;; Enable a 5-band EQ 355 + (settings/save client 356 + {:eq-enabled true 357 + :eq-precut -3 358 + :eq-band-settings [{:cutoff 60 :q 7 :gain 3} 359 + {:cutoff 200 :q 7 :gain 0} 360 + {:cutoff 800 :q 7 :gain 0} 361 + {:cutoff 4000 :q 7 :gain -2} 362 + {:cutoff 12000 :q 7 :gain 1}]}) 363 + 364 + ;; Compressor + ReplayGain 365 + (settings/save client 366 + {:compressor-settings {:threshold -24 :makeup-gain 3 367 + :ratio 2 :knee 0 368 + :attack-time 5 :release-time 100} 369 + :replaygain-settings {:noclip true :type 1 :preamp 0}}) 370 + ``` 371 + 372 + --- 373 + 374 + ### System 375 + 376 + ```clojure 377 + (require '[rockbox.system :as sys]) 378 + 379 + (sys/version client) ;=> "1.0.0" 380 + (sys/status client) ;=> {:runtime n :topruntime n :resume-index i ...} 381 + ``` 382 + 383 + --- 384 + 385 + ### Browse (filesystem) 386 + 387 + Walk the configured `music_dir`. 388 + 389 + ```clojure 390 + (require '[rockbox.browse :as br] 391 + '[rockbox.types :as t]) 392 + 393 + (br/entries client) ; root of music_dir 394 + (br/entries client "/Music/Pink Floyd") 395 + (br/directories client "/Music") 396 + (br/files client "/Music/Pink Floyd/The Wall") 397 + 398 + ;; Or filter manually 399 + (filter t/directory? (br/entries client)) 400 + ``` 401 + 402 + --- 403 + 404 + ### Devices 405 + 406 + Output sinks discovered via mDNS — Chromecast, AirPlay, etc. 407 + 408 + ```clojure 409 + (require '[rockbox.devices :as dev]) 410 + 411 + (dev/list client) 412 + (dev/get client "device-id") 413 + (dev/connect client "chromecast-id") ; switches the active PCM sink 414 + (dev/disconnect client "chromecast-id") ; reverts to built-in 415 + ``` 416 + 417 + --- 418 + 419 + ### Bluetooth 420 + 421 + Linux-only (BlueZ via D-Bus). 422 + 423 + ```clojure 424 + (require '[rockbox.bluetooth :as bt]) 425 + 426 + (bt/devices client) 427 + (bt/scan client) ; default timeout 428 + (bt/scan client 30) ; 30 s 429 + (bt/connect client "AA:BB:CC:DD:EE:FF") 430 + (bt/disconnect client "AA:BB:CC:DD:EE:FF") 431 + ``` 432 + 433 + --- 434 + 435 + ## Real-time events 436 + 437 + Call `(rb/connect client)` to open the WebSocket. The connection is lazy 438 + (only created on first call), auto-reconnects with exponential backoff up 439 + to 30 s, and re-subscribes after every reconnect. 440 + 441 + ```clojure 442 + (require '[rockbox.core :as rb] 443 + '[rockbox.types :as t]) 444 + 445 + (rb/connect client) 446 + 447 + ;; ── Callback API ──────────────────────────────────────────────────────────── 448 + (-> client 449 + (rb/on :track-changed 450 + (fn [tr] (println "▶" (:title tr) "—" (:artist tr)))) 451 + (rb/on :status-changed 452 + (fn [raw] (println "status:" (t/playback-status->keyword raw)))) 453 + (rb/on :playlist-changed 454 + (fn [pl] (println "queue updated:" (:amount pl) "tracks"))) 455 + (rb/on :ws-error 456 + (fn [e] (println "WS error:" (.getMessage ^Throwable e))))) 457 + 458 + ;; One-shot listener 459 + (rb/once client :track-changed (fn [tr] (println "First track:" (:title tr)))) 460 + 461 + ;; Remove a listener 462 + (let [h (fn [tr] (println (:title tr)))] 463 + (rb/on client :track-changed h) 464 + ;; …later 465 + (rb/off client :track-changed h)) 466 + 467 + ;; ── core.async API ────────────────────────────────────────────────────────── 468 + (require '[clojure.core.async :as a] 469 + '[rockbox.events :as events]) 470 + 471 + (let [ch (events/channel client :track-changed)] 472 + (a/go-loop [] 473 + (when-let [tr (a/<! ch)] 474 + (println "▶" (:title tr)) 475 + (recur))) 476 + ;; …later 477 + (events/close-channel! client ch)) 478 + 479 + ;; Shut everything down 480 + (rb/disconnect client) 481 + ``` 482 + 483 + ### Event map 484 + 485 + | Event | Payload | Description | 486 + |---------------------|-------------|--------------------------------------| 487 + | `:track-changed` | track map | Currently playing track changed | 488 + | `:status-changed` | int | Playback status (0=stopped, 1=playing, 3=paused) | 489 + | `:playlist-changed` | playlist | Active queue was modified | 490 + | `:ws-open` | `nil` | WebSocket connection established | 491 + | `:ws-close` | `nil` | WebSocket connection closed | 492 + | `:ws-error` | Throwable | WebSocket / subscription error | 493 + 494 + --- 495 + 496 + ## Plugin system 497 + 498 + A plugin is a plain map with `:name`, `:install`, and (optionally) `:version`, 499 + `:description`, and `:uninstall`. Compose them with `assoc` / closures. 500 + 501 + ```clojure 502 + (defn lastfm-scrobbler [{:keys [api-key secret]}] 503 + (let [state (atom {:current nil :started-at 0})] 504 + {:name "lastfm-scrobbler" 505 + :version "1.0.0" 506 + :description "Scrobble plays > 30 s old to Last.fm" 507 + :install 508 + (fn [{:keys [client query events]}] 509 + ;; `events` is a map of helpers already partially-applied to `client` 510 + ((:on events) :track-changed 511 + (fn [tr] 512 + (let [{:keys [current started-at]} @state] 513 + (when (and current (> (- (System/currentTimeMillis) started-at) 30000)) 514 + (submit-to-lastfm api-key secret current)) 515 + (reset! state {:current tr :started-at (System/currentTimeMillis)}))))) 516 + :uninstall (fn [] (reset! state {}))})) 517 + 518 + (rb/use-plugin client (lastfm-scrobbler {:api-key "..." :secret "..."})) 519 + (rb/installed-plugins client) ;=> [{:name "lastfm-scrobbler" ...}] 520 + (rb/unuse-plugin client "lastfm-scrobbler") 521 + ``` 522 + 523 + The `install` fn receives a context map: 524 + 525 + ```clojure 526 + {:client client ; the client value 527 + :query (fn ([gql] ...) ([gql vars] ...)) 528 + :events {:on (partial events/on client) 529 + :once (partial events/once client) 530 + :off (partial events/off client) 531 + :off-all (partial events/off-all client) 532 + :channel (partial events/channel client) 533 + :close-channel (partial events/close-channel! client)}} 534 + ``` 535 + 536 + ### Plugin with custom queries 537 + 538 + ```clojure 539 + (def lyrics-plugin 540 + {:name "lyrics" 541 + :version "0.1.0" 542 + :install (fn [{:keys [query events]}] 543 + ((:on events) :track-changed 544 + (fn [tr] 545 + (when (:id tr) 546 + (let [data (query "query T($id: String!) { track(id: $id) { title artist } }" 547 + {:id (:id tr)})] 548 + (fetch-and-display-lyrics (:track data)))))))}) 549 + ``` 550 + 551 + ### Sleep timer plugin (closes over local state) 552 + 553 + ```clojure 554 + (defn sleep-timer [minutes] 555 + (let [t (atom nil)] 556 + {:name "sleep-timer" 557 + :version "1.0.0" 558 + :description (str "Stop playback after " minutes " minutes") 559 + :install 560 + (fn [{:keys [query events]}] 561 + (reset! t (future 562 + (Thread/sleep (* minutes 60 1000)) 563 + (query "mutation { hardStop }") 564 + (println "Sleep timer fired — playback stopped."))) 565 + ((:on events) :status-changed 566 + (fn [s] (when (zero? s) (some-> @t future-cancel))))) 567 + :uninstall (fn [] (some-> @t future-cancel))})) 568 + 569 + (rb/use-plugin client (sleep-timer 30)) 570 + ``` 571 + 572 + --- 573 + 574 + ## Error handling 575 + 576 + All errors are `clojure.lang.ExceptionInfo` instances carrying a `:type` key 577 + in their ex-data. One `catch ExceptionInfo` covers everything: 578 + 579 + ```clojure 580 + (require '[rockbox.errors :as err]) 581 + 582 + (try 583 + (pb/play client) 584 + (catch clojure.lang.ExceptionInfo e 585 + (case (:type (ex-data e)) 586 + :rockbox/network (println "rockboxd is offline:" (.getMessage e)) 587 + :rockbox/graphql (doseq [g (:errors (ex-data e))] 588 + (println "GraphQL:" (:message g) (:path g))) 589 + :rockbox/config (println "Bad input:" (.getMessage e)) 590 + (throw e)))) 591 + 592 + ;; Predicates 593 + (err/network-error? e) 594 + (err/graphql-error? e) 595 + ``` 596 + 597 + | `:type` | When thrown | 598 + |--------------------|-----------------------------------------------------------------| 599 + | `:rockbox/network` | Cannot reach rockboxd, or HTTP returned a non-2xx status | 600 + | `:rockbox/graphql` | Server returned `{errors: [...]}` in the response body | 601 + | `:rockbox/config` | Client constructed with bad config or required input missing | 602 + 603 + --- 604 + 605 + ## Raw GraphQL queries 606 + 607 + For operations not yet covered by the SDK, use `rb/query`. The GraphiQL 608 + explorer is available at `http://localhost:6062/graphiql` while rockboxd 609 + is running. 610 + 611 + ```clojure 612 + ;; Simple query 613 + (rb/query client "query { rockboxVersion }") 614 + ;=> {:rockbox-version "1.0.0"} 615 + 616 + ;; With variables — kebab-case is auto-converted to camelCase 617 + (rb/query client 618 + "query Album($id: String!) { 619 + album(id: $id) { id title artist year } 620 + }" 621 + {:id "abc-123"}) 622 + 623 + ;; Mutation 624 + (rb/query client 625 + "mutation Seek($t: Int!) { fastForwardRewind(newTime: $t) }" 626 + {:t 120000}) 627 + ``` 628 + 629 + --- 630 + 631 + ## Types reference 632 + 633 + Enum constants and helpers live in `rockbox.types`: 634 + 635 + ```clojure 636 + (require '[rockbox.types :as t]) 637 + 638 + t/playback-status ;=> {:stopped 0, :playing 1, :paused 3} 639 + t/playback-status->keyword ;=> {0 :stopped, 1 :playing, 3 :paused} 640 + t/playing ;=> 1 641 + t/repeat-mode ;=> {:off 0, :all 1, :one 2, :shuffle 3, :ab-repeat 4} 642 + t/channel-config ;=> {:stereo 0, :stereo-narrow 1, ...} 643 + t/replaygain-type ;=> {:track 0, :album 1, :shuffle 2} 644 + t/insert-position ;=> {:next 0, :after-current 1, :last 2, :first 3} 645 + 646 + (t/->insert-position :next) ; coerce keyword or int -> int 647 + (t/directory? entry) ; tests entry's :attr bitmask 648 + (t/file? entry) 649 + (t/format-ms 75000) ;=> "1:15" 650 + ``` 651 + 652 + ### Selected response shapes 653 + 654 + `Track` (kebab-case keys): 655 + 656 + ```clojure 657 + {:id "..." :title "..." :artist "..." :album "..." 658 + :genre "..." :album-artist "..." :composer "..." 659 + :tracknum 1 :discnum 1 :year 1973 660 + :bitrate 320 :frequency 44100 661 + :length 12345 ; ms 662 + :elapsed 6789 ; ms 663 + :filesize 4567890 :path "/Music/..." 664 + :album-id "..." :artist-id "..." :album-art "..."} 665 + ``` 666 + 667 + `Playlist`: 668 + 669 + ```clojure 670 + {:amount 12 :index 3 :max-playlist-size 32000 671 + :first-index 0 :last-insert-pos -1 672 + :seed 0 :last-shuffled-start 0 673 + :tracks [...]} 674 + ``` 675 + 676 + `Device`: 677 + 678 + ```clojure 679 + {:id "..." :name "..." :host "..." :ip "..." :port 8009 680 + :service "..." :app "..." :base-url "..." 681 + :is-connected false 682 + :is-cast-device true 683 + :is-source-device false 684 + :is-current-device false} 685 + ``` 686 + 687 + --- 688 + 689 + ## License 690 + 691 + MIT License. See [LICENSE](./LICENSE) for details.
+19
sdk/clojure/deps.edn
··· 1 + {:paths ["src"] 2 + :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 + org.clojure/data.json {:mvn/version "2.5.0"} 4 + org.clojure/core.async {:mvn/version "1.6.681"}} 5 + 6 + :aliases 7 + {:test 8 + {:extra-paths ["test"] 9 + :extra-deps {io.github.cognitect-labs/test-runner 10 + {:git/tag "v0.5.1" :git/sha "dfb30dd"}} 11 + :main-opts ["-m" "cognitect.test-runner"] 12 + :exec-fn cognitect.test-runner.api/test} 13 + 14 + :examples 15 + {:extra-paths ["examples"]} 16 + 17 + :check 18 + {:main-opts ["-e" 19 + "(require '[rockbox.core] '[rockbox.playback] '[rockbox.library] '[rockbox.playlist] '[rockbox.saved-playlists] '[rockbox.smart-playlists] '[rockbox.sound] '[rockbox.settings] '[rockbox.system] '[rockbox.browse] '[rockbox.devices] '[rockbox.bluetooth] '[rockbox.events] '[rockbox.plugin] '[rockbox.transport] '[rockbox.ws] '[rockbox.types]) (println :ok)"]}}}
+27
sdk/clojure/examples/README.md
··· 1 + # Examples 2 + 3 + Each file is a runnable Clojure script. They share `example_client.clj`, which 4 + builds a `RockboxClient` honouring `ROCKBOX_HOST` / `ROCKBOX_PORT` env vars. 5 + 6 + ```sh 7 + # From sdk/clojure 8 + clj -M:examples -m ex01-basic-playback 9 + clj -M:examples -m ex03-library-search "pink floyd" 10 + ROCKBOX_HOST=192.168.1.42 clj -M:examples -m ex02-now-playing 11 + ``` 12 + 13 + | File | What it shows | 14 + |-----------------------------------|-------------------------------------------------------| 15 + | `ex01_basic_playback.clj` | Pause / seek / resume in one threading-macro chain | 16 + | `ex02_now_playing.clj` | Pretty-print the currently playing track | 17 + | `ex03_library_search.clj` | Search → play first matching album shuffled | 18 + | `ex04_queue_management.clj` | Inspect and modify the live queue | 19 + | `ex05_realtime_events.clj` | WebSocket events with the callback API | 20 + | `ex06_core_async_events.clj` | Same events, consumed via `core.async` channels | 21 + | `ex07_volume_eq.clj` | Adjust volume + write a 5-band EQ preset | 22 + | `ex08_browse_filesystem.clj` | Walk `music_dir` (directories vs files) | 23 + | `ex09_plugin_scrobbler.clj` | Toy "scrobbler" plugin via `use-plugin` / event hook | 24 + | `ex10_smart_playlist.clj` | Create a smart playlist from a Clojure data rule-set | 25 + 26 + `rockboxd` must be running locally (or specify `ROCKBOX_HOST`) before the 27 + examples can connect.
+22
sdk/clojure/examples/ex01_basic_playback.clj
··· 1 + (ns ex01-basic-playback 2 + "Pause, seek to 1:30, resume — using the threading macro for a pipe-friendly 3 + call chain. Action functions return the client so they compose with `->`." 4 + (:require [example-client :as client] 5 + [rockbox.core :as rb] 6 + [rockbox.playback :as pb] 7 + [rockbox.types :as t])) 8 + 9 + (defn -main [& _] 10 + (let [c (client/make-client)] 11 + (println "Status:" (pb/status c)) 12 + (when-let [track (pb/current-track c)] 13 + (printf "Now playing: %s — %s (%s / %s)%n" 14 + (:title track) (:artist track) 15 + (t/format-ms (:elapsed track)) (t/format-ms (:length track)))) 16 + 17 + (-> c 18 + (pb/pause) 19 + (pb/seek 90000) ; jump to 1:30 20 + (pb/resume)) 21 + 22 + (println "Status after pipe:" (pb/status c))))
+22
sdk/clojure/examples/ex02_now_playing.clj
··· 1 + (ns ex02-now-playing 2 + "Fetch the currently playing track and pretty-print its details." 3 + (:require [example-client :as client] 4 + [rockbox.playback :as pb] 5 + [rockbox.types :as t])) 6 + 7 + (defn -main [& _] 8 + (let [c (client/make-client) 9 + track (pb/current-track c)] 10 + (if track 11 + (do 12 + (println "──────── Now playing ────────") 13 + (printf " Title : %s%n" (:title track)) 14 + (printf " Artist : %s%n" (:artist track)) 15 + (printf " Album : %s (%d)%n" (:album track) (or (:year track) 0)) 16 + (printf " Position : %s / %s%n" 17 + (t/format-ms (:elapsed track)) 18 + (t/format-ms (:length track))) 19 + (printf " Bitrate : %d kbps @ %d Hz%n" 20 + (or (:bitrate track) 0) (or (:frequency track) 0)) 21 + (printf " Path : %s%n" (:path track))) 22 + (println "Nothing is playing."))))
+24
sdk/clojure/examples/ex03_library_search.clj
··· 1 + (ns ex03-library-search 2 + "Search the library and play the first matching album, shuffled." 3 + (:require [example-client :as client] 4 + [rockbox.library :as lib] 5 + [rockbox.playback :as pb])) 6 + 7 + (defn -main [& args] 8 + (let [term (or (first args) "radiohead") 9 + c (client/make-client) 10 + {:keys [artists albums tracks]} (lib/search c term)] 11 + 12 + (printf "Searching for %s%n%n" (pr-str term)) 13 + (printf "Artists (%d):%n" (count artists)) 14 + (doseq [a (take 5 artists)] (printf " • %s%n" (:name a))) 15 + 16 + (printf "%nAlbums (%d):%n" (count albums)) 17 + (doseq [a (take 5 albums)] (printf " • %s — %s (%d)%n" (:title a) (:artist a) (or (:year a) 0))) 18 + 19 + (printf "%nTracks (%d):%n" (count tracks)) 20 + (doseq [t (take 5 tracks)] (printf " • %s — %s%n" (:title t) (:artist t))) 21 + 22 + (when-let [first-album (first albums)] 23 + (printf "%n▶ Playing %s (shuffled)…%n" (:title first-album)) 24 + (pb/play-album c (:id first-album) {:shuffle true}))))
+22
sdk/clojure/examples/ex04_queue_management.clj
··· 1 + (ns ex04-queue-management 2 + "Inspect and modify the live playback queue." 3 + (:require [example-client :as client] 4 + [rockbox.playlist :as q] 5 + [rockbox.types :as t])) 6 + 7 + (defn -main [& _] 8 + (let [c (client/make-client) 9 + {:keys [tracks index amount]} (q/current c)] 10 + (printf "Queue — %d tracks, currently at index %d%n%n" amount index) 11 + (doseq [[i tr] (map-indexed vector (take 10 tracks))] 12 + (printf "%s %2d. %s — %s [%s]%n" 13 + (if (= i index) "▶" " ") 14 + (inc i) (:title tr) (:artist tr) 15 + (t/format-ms (:length tr)))) 16 + 17 + ;; Pipe-friendly mutation chain 18 + (-> c 19 + (q/insert-tracks ["/Music/example.mp3"] :next) 20 + (q/shuffle)) 21 + 22 + (println "\nQueue after shuffle:" (q/amount c))))
+26
sdk/clojure/examples/ex05_realtime_events.clj
··· 1 + (ns ex05-realtime-events 2 + "Open the WebSocket and react to track / status changes for 60 seconds." 3 + (:require [example-client :as client] 4 + [rockbox.core :as rb] 5 + [rockbox.types :as t])) 6 + 7 + (defn -main [& _] 8 + (let [c (client/make-client)] 9 + (-> c 10 + (rb/connect) 11 + (rb/on :track-changed 12 + (fn [tr] 13 + (printf "▶ %s — %s%n" (:title tr) (:artist tr)))) 14 + (rb/on :status-changed 15 + (fn [raw] 16 + (printf " status: %s%n" (t/playback-status->keyword raw)))) 17 + (rb/on :playlist-changed 18 + (fn [pl] 19 + (printf " queue updated: %d tracks%n" (:amount pl)))) 20 + (rb/on :ws-error 21 + (fn [e] 22 + (println "WS error:" (or (.getMessage ^Throwable e) e))))) 23 + 24 + (println "Listening for 60 s — press Ctrl-C to quit early.") 25 + (Thread/sleep 60000) 26 + (rb/disconnect c)))
+28
sdk/clojure/examples/ex06_core_async_events.clj
··· 1 + (ns ex06-core-async-events 2 + "Same idea as 05, but consume events through a core.async channel — 3 + natural when integrating with a `go-loop`-based event pipeline." 4 + (:require [example-client :as client] 5 + [clojure.core.async :as a] 6 + [rockbox.core :as rb] 7 + [rockbox.events :as events])) 8 + 9 + (defn -main [& _] 10 + (let [c (client/make-client) 11 + _ (rb/connect c) 12 + track-ch (events/channel c :track-changed) 13 + status-ch (events/channel c :status-changed)] 14 + 15 + (a/go-loop [] 16 + (when-let [tr (a/<! track-ch)] 17 + (printf "▶ %s — %s%n" (:title tr) (:artist tr)) 18 + (recur))) 19 + 20 + (a/go-loop [] 21 + (when-let [s (a/<! status-ch)] 22 + (printf " status raw=%d%n" s) 23 + (recur))) 24 + 25 + (Thread/sleep 60000) 26 + (events/close-channel! c track-ch) 27 + (events/close-channel! c status-ch) 28 + (rb/disconnect c)))
+22
sdk/clojure/examples/ex07_volume_eq.clj
··· 1 + (ns ex07-volume-eq 2 + "Volume control + a tasteful EQ preset, all in one piped chain." 3 + (:require [example-client :as client] 4 + [rockbox.sound :as snd] 5 + [rockbox.settings :as settings])) 6 + 7 + (defn -main [& _] 8 + (let [c (client/make-client) 9 + {:keys [volume min max]} (snd/volume c)] 10 + (printf "Current volume: %d (range %d..%d)%n" volume min max) 11 + (printf "Bumped to: %d%n" (snd/volume-up c)) 12 + 13 + (settings/save c 14 + {:eq-enabled true 15 + :eq-precut -3 16 + :eq-band-settings [{:cutoff 60 :q 7 :gain 3} ; bass boost 17 + {:cutoff 200 :q 7 :gain 0} 18 + {:cutoff 800 :q 7 :gain 0} 19 + {:cutoff 4000 :q 7 :gain -2} ; tame the presence band 20 + {:cutoff 12000 :q 7 :gain 1}]}) 21 + 22 + (println "EQ enabled with a 5-band preset.")))
+13
sdk/clojure/examples/ex08_browse_filesystem.clj
··· 1 + (ns ex08-browse-filesystem 2 + "Walk the configured music_dir and print directories vs files." 3 + (:require [example-client :as client] 4 + [rockbox.browse :as br])) 5 + 6 + (defn -main [& args] 7 + (let [c (client/make-client) 8 + path (or (first args) nil)] 9 + (printf "── %s ──%n" (or path "<music_dir root>")) 10 + (doseq [d (br/directories c path)] 11 + (printf "📁 %s%n" (:name d))) 12 + (doseq [f (br/files c path)] 13 + (printf "🎵 %s%n" (:name f)))))
+33
sdk/clojure/examples/ex09_plugin_scrobbler.clj
··· 1 + (ns ex09-plugin-scrobbler 2 + "A toy scrobbler plugin that reports plays > 30 s old." 3 + (:require [example-client :as client] 4 + [rockbox.core :as rb])) 5 + 6 + ;; A plugin is a plain map. Compose new ones with `assoc` / `merge`. 7 + (defn scrobbler [submit-fn] 8 + (let [state (atom {:current nil :started-at 0})] 9 + {:name "scrobbler" 10 + :version "0.1.0" 11 + :description "Submits plays > 30 s old to `submit-fn`" 12 + :install (fn [{:keys [events]}] 13 + ((:on events) :track-changed 14 + (fn [tr] 15 + (let [{:keys [current started-at]} @state] 16 + (when (and current 17 + (> (- (System/currentTimeMillis) started-at) 30000)) 18 + (submit-fn current)) 19 + (reset! state {:current tr :started-at (System/currentTimeMillis)}))))) 20 + :uninstall (fn [] (reset! state {:current nil :started-at 0}))})) 21 + 22 + (defn -main [& _] 23 + (let [c (client/make-client)] 24 + (-> c 25 + (rb/connect) 26 + (rb/use-plugin (scrobbler (fn [tr] 27 + (printf "📡 scrobble %s — %s%n" 28 + (:title tr) (:artist tr)))))) 29 + (println "Installed plugins:" (mapv :name (rb/installed-plugins c))) 30 + (println "Listening for 5 minutes…") 31 + (Thread/sleep (* 5 60 1000)) 32 + (rb/unuse-plugin c "scrobbler") 33 + (rb/disconnect c)))
+21
sdk/clojure/examples/ex10_smart_playlist.clj
··· 1 + (ns ex10-smart-playlist 2 + "Create a 'recently played' smart playlist using a Clojure data rule set — 3 + the SDK encodes it to JSON for you." 4 + (:require [example-client :as client] 5 + [rockbox.smart-playlists :as sp])) 6 + 7 + (defn -main [& _] 8 + (let [c (client/make-client) 9 + new-pl (sp/create c 10 + {:name "Recently played" 11 + :rules {:operator "AND" 12 + :rules [{:field "play_count" :op "gt" :value 0} 13 + {:field "last_played" :op "within" :value "30d"}]}})] 14 + (printf "Created %s (%s)%n" (:name new-pl) (:id new-pl)) 15 + 16 + (printf "Currently resolves to %d tracks%n" 17 + (count (sp/track-ids c (:id new-pl)))) 18 + 19 + ;; Cleanup so the example is repeatable 20 + (sp/delete c (:id new-pl)) 21 + (println "Deleted.")))
+15
sdk/clojure/examples/example_client.clj
··· 1 + (ns example-client 2 + "Shared client used by the example scripts. 3 + 4 + Run an example with: 5 + 6 + clj -M:examples -m ex01-basic-playback 7 + 8 + Override the host/port via env vars: 9 + 10 + ROCKBOX_HOST=192.168.1.42 ROCKBOX_PORT=6062 clj -M:examples -m ex01-basic-playback" 11 + (:require [rockbox.core :as rb])) 12 + 13 + (defn make-client [] 14 + (rb/client {:host (or (System/getenv "ROCKBOX_HOST") "localhost") 15 + :port (Integer/parseInt (or (System/getenv "ROCKBOX_PORT") "6062"))}))
+43
sdk/clojure/src/rockbox/bluetooth.clj
··· 1 + (ns rockbox.bluetooth 2 + "Bluetooth devices (Linux only — wraps BlueZ via D-Bus)." 3 + (:require [rockbox.transport :as t])) 4 + 5 + (def ^:private device-fields 6 + "fragment BluetoothDeviceFields on BluetoothDevice { 7 + address name paired trusted connected rssi 8 + }") 9 + 10 + (defn devices 11 + "List paired/known Bluetooth devices." 12 + [client] 13 + (:bluetooth-devices 14 + (t/execute client (str device-fields 15 + " query BluetoothDevices { bluetoothDevices { ...BluetoothDeviceFields } }")))) 16 + 17 + (defn scan 18 + "Scan for nearby Bluetooth devices. Optional `timeout-secs`." 19 + ([client] (scan client nil)) 20 + ([client timeout-secs] 21 + (:bluetooth-scan 22 + (t/execute client 23 + (str device-fields 24 + " mutation BluetoothScan($timeoutSecs: Int) { 25 + bluetoothScan(timeoutSecs: $timeoutSecs) { ...BluetoothDeviceFields } 26 + }") 27 + {:timeout-secs timeout-secs})))) 28 + 29 + (defn connect 30 + "Connect to a Bluetooth device by address." 31 + [client address] 32 + (t/execute client 33 + "mutation BluetoothConnect($address: String!) { bluetoothConnect(address: $address) }" 34 + {:address address}) 35 + client) 36 + 37 + (defn disconnect 38 + "Disconnect a Bluetooth device by address." 39 + [client address] 40 + (t/execute client 41 + "mutation BluetoothDisconnect($address: String!) { bluetoothDisconnect(address: $address) }" 42 + {:address address}) 43 + client)
+25
sdk/clojure/src/rockbox/browse.clj
··· 1 + (ns rockbox.browse 2 + "Filesystem browser — walk the configured `music_dir`." 3 + (:require [rockbox.transport :as t] 4 + [rockbox.types :as types])) 5 + 6 + (defn entries 7 + "All entries (files + directories) under `path` (or root of music_dir)." 8 + ([client] (entries client nil)) 9 + ([client path] 10 + (:tree-get-entries 11 + (t/execute client 12 + "query Browse($path: String) { 13 + treeGetEntries(path: $path) { name attr timeWrite customaction displayName } 14 + }" 15 + {:path path})))) 16 + 17 + (defn directories 18 + "Only directory entries." 19 + ([client] (directories client nil)) 20 + ([client path] (filterv types/directory? (entries client path)))) 21 + 22 + (defn files 23 + "Only file entries." 24 + ([client] (files client nil)) 25 + ([client path] (filterv types/file? (entries client path))))
+216
sdk/clojure/src/rockbox/core.clj
··· 1 + (ns rockbox.core 2 + "Idiomatic Clojure SDK for [Rockbox](https://www.rockbox.org). 3 + 4 + ## Quick start 5 + 6 + (require '[rockbox.core :as rb] 7 + '[rockbox.playback :as pb] 8 + '[rockbox.library :as lib]) 9 + 10 + (def client (rb/client)) 11 + 12 + ;; Optional: open the WebSocket for real-time events 13 + (rb/connect client) 14 + (rb/on client :track-changed 15 + (fn [t] (println \"▶\" (:title t) \"—\" (:artist t)))) 16 + 17 + ;; Look at what's playing 18 + (when-let [t (pb/current-track client)] 19 + (println (:title t))) 20 + 21 + ;; Pipe-friendly: actions return the client so they compose 22 + (-> client 23 + (pb/pause) 24 + (pb/seek 90000) 25 + (pb/resume)) 26 + 27 + ## Module map 28 + 29 + | Domain | Namespace | 30 + |-----------------------|----------------------------| 31 + | Transport controls | `rockbox.playback` | 32 + | Library / search | `rockbox.library` | 33 + | Live queue | `rockbox.playlist` | 34 + | Saved playlists | `rockbox.saved-playlists` | 35 + | Smart playlists | `rockbox.smart-playlists` | 36 + | Volume | `rockbox.sound` | 37 + | Settings | `rockbox.settings` | 38 + | System info | `rockbox.system` | 39 + | Filesystem browser | `rockbox.browse` | 40 + | Output devices | `rockbox.devices` | 41 + | Bluetooth (Linux) | `rockbox.bluetooth` | 42 + | Real-time events | `rockbox.events` | 43 + | Plugin system | `rockbox.plugin` | 44 + | Enums and helpers | `rockbox.types` |" 45 + (:require [rockbox.transport :as transport] 46 + [rockbox.events :as events] 47 + [rockbox.plugin :as plugin] 48 + [rockbox.ws :as ws])) 49 + 50 + ;; --------------------------------------------------------------------------- 51 + ;; Client construction 52 + ;; --------------------------------------------------------------------------- 53 + 54 + (def ^:const default-host "localhost") 55 + (def ^:const default-port 6062) 56 + (def ^:const default-timeout-ms 15000) 57 + 58 + (defn client 59 + "Build a Rockbox client. 60 + 61 + Options (all optional): 62 + 63 + :host hostname or IP of rockboxd (default \"localhost\") 64 + :port GraphQL HTTP/WS port (default 6062) 65 + :http-url full HTTP URL override (overrides host/port) 66 + :ws-url full WebSocket URL override (overrides host/port) 67 + :timeout-ms request timeout in milliseconds (default 15000) 68 + :headers map of extra HTTP headers 69 + :http-client java.net.http.HttpClient instance to reuse 70 + 71 + The returned value is a plain map — passable to all `rockbox.*` functions 72 + as their first argument so calls compose with `->`. 73 + 74 + (def c (rb/client)) 75 + (def c (rb/client {:host \"music.home\" :port 6062})) 76 + (def c (rb/client {:http-url \"https://music.home/graphql\"}))" 77 + ([] (client {})) 78 + ([{:keys [host port http-url ws-url timeout-ms headers http-client]}] 79 + (let [host (or host default-host) 80 + port (or port default-port) 81 + http (or http-url (str "http://" host ":" port "/graphql")) 82 + wss (or ws-url (str "ws://" host ":" port "/graphql"))] 83 + {:host host 84 + :port port 85 + :http-url http 86 + :ws-url wss 87 + :timeout-ms (or timeout-ms default-timeout-ms) 88 + :headers (or headers {}) 89 + :http-client http-client 90 + :listeners (atom {}) 91 + :plugins (atom {}) 92 + :ws-conn (atom nil)}))) 93 + 94 + ;; --- builder-style helpers (composable via `->`) ---------------------------- 95 + 96 + (defn- rebuild-urls [c] 97 + (assoc c 98 + :http-url (str "http://" (:host c) ":" (:port c) "/graphql") 99 + :ws-url (str "ws://" (:host c) ":" (:port c) "/graphql"))) 100 + 101 + (defn with-host 102 + "Return `c` with `:host` set to `h` (and URLs rebuilt). Pipe-friendly." 103 + [c h] (rebuild-urls (assoc c :host h))) 104 + 105 + (defn with-port [c p] (rebuild-urls (assoc c :port p))) 106 + (defn with-timeout 107 + "Return `c` with `:timeout-ms` set. Pipe-friendly." 108 + [c ms] (assoc c :timeout-ms ms)) 109 + (defn with-headers 110 + "Merge extra headers into `c`. Pipe-friendly." 111 + [c h] (update c :headers merge h)) 112 + (defn with-http-url [c url] (assoc c :http-url url)) 113 + (defn with-ws-url [c url] (assoc c :ws-url url)) 114 + 115 + ;; --------------------------------------------------------------------------- 116 + ;; Real-time subscriptions 117 + ;; --------------------------------------------------------------------------- 118 + 119 + (def ^:private currently-playing-query 120 + "subscription CurrentlyPlaying { 121 + currentlyPlayingSong { 122 + id title artist album albumArt albumId artistId path length elapsed 123 + } 124 + }") 125 + 126 + (def ^:private playback-status-query 127 + "subscription PlaybackStatus { playbackStatus { status } }") 128 + 129 + (def ^:private playlist-changed-query 130 + "subscription PlaylistChanged { 131 + playlistChanged { 132 + amount index maxPlaylistSize firstIndex lastInsertPos seed lastShuffledStart 133 + tracks { id title artist album path length albumArt } 134 + } 135 + }") 136 + 137 + (defn connect 138 + "Open the WebSocket and start the three default subscriptions 139 + (track / status / playlist). Idempotent. Returns the client." 140 + [client] 141 + (when-not @(:ws-conn client) 142 + (let [conn (ws/open client (:ws-url client))] 143 + (reset! (:ws-conn client) conn) 144 + (ws/subscribe conn currently-playing-query nil 145 + {:next (fn [{:keys [data]}] 146 + (when-let [track (:currently-playing-song data)] 147 + (events/emit client :track-changed track))) 148 + :error (fn [e] (events/emit client :ws-error e)) 149 + :complete (fn [])}) 150 + (ws/subscribe conn playback-status-query nil 151 + {:next (fn [{:keys [data]}] 152 + (when-let [s (get-in data [:playback-status :status])] 153 + (events/emit client :status-changed s))) 154 + :error (fn [e] (events/emit client :ws-error e)) 155 + :complete (fn [])}) 156 + (ws/subscribe conn playlist-changed-query nil 157 + {:next (fn [{:keys [data]}] 158 + (when-let [pl (:playlist-changed data)] 159 + (events/emit client :playlist-changed pl))) 160 + :error (fn [e] (events/emit client :ws-error e)) 161 + :complete (fn [])}))) 162 + client) 163 + 164 + (defn disconnect 165 + "Close the WebSocket connection. Returns the client." 166 + [client] 167 + (when-let [conn @(:ws-conn client)] 168 + (ws/close conn) 169 + (reset! (:ws-conn client) nil)) 170 + client) 171 + 172 + ;; --------------------------------------------------------------------------- 173 + ;; Event API — re-exported from rockbox.events for convenience 174 + ;; --------------------------------------------------------------------------- 175 + 176 + (def ^{:doc "See `rockbox.events/on`."} on events/on) 177 + (def ^{:doc "See `rockbox.events/once`."} once events/once) 178 + (def ^{:doc "See `rockbox.events/off`."} off events/off) 179 + (def ^{:doc "See `rockbox.events/off-all`."} off-all events/off-all) 180 + (def ^{:doc "See `rockbox.events/channel`."} channel events/channel) 181 + 182 + ;; --------------------------------------------------------------------------- 183 + ;; Plugin API — re-exported 184 + ;; --------------------------------------------------------------------------- 185 + 186 + (defn use-plugin 187 + "Install a plugin. See `rockbox.plugin`. Returns the client." 188 + [client plugin] 189 + (plugin/install client plugin)) 190 + 191 + (defn unuse-plugin 192 + "Uninstall a plugin by name. Returns the client." 193 + [client plugin-name] 194 + (plugin/uninstall client plugin-name)) 195 + 196 + (defn installed-plugins 197 + "List installed plugins." 198 + [client] 199 + (plugin/installed client)) 200 + 201 + ;; --------------------------------------------------------------------------- 202 + ;; Raw GraphQL escape hatch 203 + ;; --------------------------------------------------------------------------- 204 + 205 + (defn query 206 + "Execute a raw GraphQL query/mutation. Returns the kebabized `data` map. 207 + Use this for operations not yet covered by the SDK. 208 + 209 + (rb/query client \"query { rockboxVersion }\") 210 + ;=> {:rockbox-version \"1.0.0\"} 211 + 212 + (rb/query client 213 + \"query Album($id: String!) { album(id: $id) { id title } }\" 214 + {:id \"abc-123\"})" 215 + ([client gql] (transport/execute client gql)) 216 + ([client gql vars] (transport/execute client gql vars)))
+33
sdk/clojure/src/rockbox/devices.clj
··· 1 + (ns rockbox.devices 2 + "Remote output sinks discovered via mDNS — Chromecast, AirPlay, etc." 3 + (:refer-clojure :exclude [list get]) 4 + (:require [rockbox.transport :as t])) 5 + 6 + (def ^:private device-fields 7 + "id name host ip port service app isConnected 8 + baseUrl isCastDevice isSourceDevice isCurrentDevice") 9 + 10 + (defn list 11 + "All discovered devices." 12 + [client] 13 + (:devices 14 + (t/execute client (str "query Devices { devices { " device-fields " } }")))) 15 + 16 + (defn get 17 + "Single device by id, or `nil`." 18 + [client id] 19 + (:device 20 + (t/execute client (str "query Device($id: String!) { device(id: $id) { " device-fields " } }") 21 + {:id id}))) 22 + 23 + (defn connect 24 + "Switch the active PCM output sink to this device." 25 + [client id] 26 + (t/execute client "mutation ConnectDevice($id: String!) { connect(id: $id) }" {:id id}) 27 + client) 28 + 29 + (defn disconnect 30 + "Revert to the built-in PCM sink." 31 + [client id] 32 + (t/execute client "mutation DisconnectDevice($id: String!) { disconnect(id: $id) }" {:id id}) 33 + client)
+48
sdk/clojure/src/rockbox/errors.clj
··· 1 + (ns rockbox.errors 2 + "Typed exceptions for the Rockbox SDK. 3 + 4 + All errors are `clojure.lang.ExceptionInfo` instances carrying a `:type` 5 + key in their ex-data, so they can be discriminated with `ex-data` / 6 + `(:type ...)` in a single `catch ExceptionInfo` block. 7 + 8 + (try 9 + (rb/query client \"...\") 10 + (catch clojure.lang.ExceptionInfo e 11 + (case (:type (ex-data e)) 12 + :rockbox/network (handle-offline e) 13 + :rockbox/graphql (handle-server-error e) 14 + (throw e))))") 15 + 16 + (defn network-error 17 + "Throwable: rockboxd is unreachable, or HTTP returned a non-2xx status." 18 + ([msg] (network-error msg nil)) 19 + ([msg cause] 20 + (ex-info msg 21 + {:type :rockbox/network 22 + :cause cause} 23 + (when (instance? Throwable cause) cause)))) 24 + 25 + (defn graphql-error 26 + "Throwable: server returned `{errors: [...]}` in the response body. 27 + `errors` is the raw vector from the server (kebabized maps)." 28 + [errors] 29 + (ex-info (->> errors (map :message) (clojure.string/join "; ")) 30 + {:type :rockbox/graphql 31 + :errors errors})) 32 + 33 + (defn config-error 34 + "Throwable: client was constructed with bad/missing config." 35 + [msg] 36 + (ex-info msg {:type :rockbox/config})) 37 + 38 + (defn network-error? 39 + "True if `e` is a Rockbox network error." 40 + [e] 41 + (and (instance? clojure.lang.ExceptionInfo e) 42 + (= :rockbox/network (:type (ex-data e))))) 43 + 44 + (defn graphql-error? 45 + "True if `e` is a Rockbox GraphQL error." 46 + [e] 47 + (and (instance? clojure.lang.ExceptionInfo e) 48 + (= :rockbox/graphql (:type (ex-data e)))))
+128
sdk/clojure/src/rockbox/events.clj
··· 1 + (ns rockbox.events 2 + "Event registry and dispatch. 3 + 4 + Listeners are stored on the client itself (in an atom) so a single client 5 + value can be passed around and shared between threads safely. 6 + 7 + ## Pipe-friendly callback API 8 + 9 + (-> client 10 + (rb/connect) 11 + (events/on :track-changed (fn [t] (println \"▶\" (:title t)))) 12 + (events/on :status-changed (fn [s] (println \"status:\" s)))) 13 + 14 + ## core.async channel API 15 + 16 + (require '[clojure.core.async :as a]) 17 + (def ch (events/channel client :track-changed)) 18 + (a/go-loop [] 19 + (when-let [t (a/<! ch)] 20 + (println (:title t)) 21 + (recur))) 22 + 23 + Supported events: `:track-changed :status-changed :playlist-changed 24 + :ws-open :ws-close :ws-error`" 25 + (:require [clojure.core.async :as a])) 26 + 27 + (def event-keys 28 + #{:track-changed :status-changed :playlist-changed 29 + :ws-open :ws-close :ws-error}) 30 + 31 + (defn- valid-event! [event] 32 + (when-not (contains? event-keys event) 33 + (throw (ex-info (str "Unknown event: " event ". Valid events: " event-keys) 34 + {:type :rockbox/config :event event})))) 35 + 36 + ;; --------------------------------------------------------------------------- 37 + ;; Registry primitives — operate on the client's `:listeners` atom 38 + ;; --------------------------------------------------------------------------- 39 + 40 + (defn- listeners-atom [client] 41 + (or (:listeners client) 42 + (throw (ex-info "Client has no listeners atom — was it built with rockbox.core/client?" 43 + {:type :rockbox/config})))) 44 + 45 + (defn on 46 + "Register a listener for an event. Returns the client (so the call composes 47 + with `->`). The listener receives one argument — the event payload (or `nil` 48 + for events with no payload, like `:ws-open`)." 49 + [client event listener] 50 + (valid-event! event) 51 + (swap! (listeners-atom client) update event (fnil conj #{}) listener) 52 + client) 53 + 54 + (defn once 55 + "Register a one-shot listener. Returns the client." 56 + [client event listener] 57 + (valid-event! event) 58 + (let [registry (listeners-atom client) 59 + wrapped (atom nil)] 60 + (reset! wrapped 61 + (fn [payload] 62 + (swap! registry update event disj @wrapped) 63 + (listener payload))) 64 + (swap! registry update event (fnil conj #{}) @wrapped) 65 + client)) 66 + 67 + (defn off 68 + "Remove a listener. Returns the client." 69 + [client event listener] 70 + (valid-event! event) 71 + (swap! (listeners-atom client) update event (fnil disj #{}) listener) 72 + client) 73 + 74 + (defn off-all 75 + "Remove every listener (or every listener for a single event)." 76 + ([client] 77 + (reset! (listeners-atom client) {}) 78 + client) 79 + ([client event] 80 + (valid-event! event) 81 + (swap! (listeners-atom client) dissoc event) 82 + client)) 83 + 84 + (defn emit 85 + "Internal — call every listener for `event`. Used by the WS layer." 86 + [client event payload] 87 + (doseq [f (get @(listeners-atom client) event)] 88 + (try (f payload) 89 + (catch Throwable t 90 + ;; Don't let one rogue listener break the others. 91 + (when-not (= event :ws-error) 92 + (doseq [g (get @(listeners-atom client) :ws-error)] 93 + (try (g t) (catch Throwable _ nil)))))))) 94 + 95 + ;; --------------------------------------------------------------------------- 96 + ;; core.async bridge 97 + ;; --------------------------------------------------------------------------- 98 + 99 + (defn channel 100 + "Return a `core.async` channel that receives every payload for `event`. 101 + The returned channel is closed when its underlying listener is removed via 102 + `close-channel!`. Buffer defaults to 16; pass `{:buf n}` (or a buffer) to 103 + override. 104 + 105 + (def ch (events/channel client :track-changed)) 106 + (a/go-loop [] 107 + (when-let [t (a/<! ch)] 108 + (println (:title t)) 109 + (recur)))" 110 + ([client event] (channel client event {})) 111 + ([client event {:keys [buf] :or {buf 16}}] 112 + (valid-event! event) 113 + (let [ch (a/chan buf) 114 + f (fn [payload] (a/put! ch payload))] 115 + (on client event f) 116 + ;; Stash the listener fn on the channel's metadata so close-channel! can 117 + ;; find it. core.async chans are not IObj, so use a side table. 118 + (swap! (listeners-atom client) update ::channels assoc ch [event f]) 119 + ch))) 120 + 121 + (defn close-channel! 122 + "Close a channel returned by `channel` and unregister its underlying listener." 123 + [client ch] 124 + (when-let [[event f] (get-in @(listeners-atom client) [::channels ch])] 125 + (off client event f) 126 + (swap! (listeners-atom client) update ::channels dissoc ch)) 127 + (a/close! ch) 128 + client)
+147
sdk/clojure/src/rockbox/library.clj
··· 1 + (ns rockbox.library 2 + "Library queries (albums, artists, tracks, search) and likes." 3 + (:require [rockbox.transport :as t])) 4 + 5 + (def ^:private track-fields 6 + "fragment TrackFields on Track { 7 + id title artist album genre disc trackString yearString 8 + composer comment albumArtist grouping 9 + discnum tracknum layer year bitrate frequency 10 + filesize length elapsed path 11 + albumId artistId genreId albumArt 12 + }") 13 + 14 + (def ^:private album-fields 15 + "fragment AlbumFields on Album { 16 + id title artist year yearString albumArt md5 artistId copyrightMessage 17 + }") 18 + 19 + (def ^:private artist-fields 20 + "fragment ArtistFields on Artist { id name bio image }") 21 + 22 + ;; --------------------------------------------------------------------------- 23 + ;; Albums 24 + ;; --------------------------------------------------------------------------- 25 + 26 + (defn albums 27 + "All albums (with shallow track stubs)." 28 + [client] 29 + (:albums (t/execute client (str album-fields 30 + " query Albums { albums { ...AlbumFields tracks { id title path length albumArt } } }")))) 31 + 32 + (defn album 33 + "Single album with full track list, or `nil` if not found." 34 + [client id] 35 + (:album (t/execute client 36 + (str track-fields album-fields 37 + " query Album($id: String!) { album(id: $id) { ...AlbumFields tracks { ...TrackFields } } }") 38 + {:id id}))) 39 + 40 + (defn liked-albums [client] 41 + (:liked-albums 42 + (t/execute client (str album-fields 43 + " query LikedAlbums { likedAlbums { ...AlbumFields } }")))) 44 + 45 + (defn like-album 46 + "Like an album. Returns the client." 47 + [client id] 48 + (t/execute client "mutation LikeAlbum($id: String!) { likeAlbum(id: $id) }" {:id id}) 49 + client) 50 + 51 + (defn unlike-album 52 + "Unlike an album. Returns the client." 53 + [client id] 54 + (t/execute client "mutation UnlikeAlbum($id: String!) { unlikeAlbum(id: $id) }" {:id id}) 55 + client) 56 + 57 + ;; --------------------------------------------------------------------------- 58 + ;; Artists 59 + ;; --------------------------------------------------------------------------- 60 + 61 + (defn artists 62 + "All artists (with shallow album stubs)." 63 + [client] 64 + (:artists 65 + (t/execute client (str artist-fields 66 + " query Artists { artists { ...ArtistFields albums { id title albumArt year } } }")))) 67 + 68 + (defn artist 69 + "Single artist with albums and tracks, or `nil`." 70 + [client id] 71 + (:artist 72 + (t/execute client 73 + (str artist-fields track-fields 74 + " query Artist($id: String!) { 75 + artist(id: $id) { 76 + ...ArtistFields 77 + albums { id title albumArt year yearString md5 artistId tracks { id title path length } } 78 + tracks { ...TrackFields } 79 + } 80 + }") 81 + {:id id}))) 82 + 83 + ;; --------------------------------------------------------------------------- 84 + ;; Tracks 85 + ;; --------------------------------------------------------------------------- 86 + 87 + (defn tracks 88 + "All tracks in the library." 89 + [client] 90 + (:tracks (t/execute client (str track-fields 91 + " query Tracks { tracks { ...TrackFields } }")))) 92 + 93 + (defn track 94 + "Single track by id, or `nil`." 95 + [client id] 96 + (:track (t/execute client (str track-fields 97 + " query Track($id: String!) { track(id: $id) { ...TrackFields } }") 98 + {:id id}))) 99 + 100 + (defn liked-tracks [client] 101 + (:liked-tracks 102 + (t/execute client (str track-fields 103 + " query LikedTracks { likedTracks { ...TrackFields } }")))) 104 + 105 + (defn like-track 106 + "Like a track. Returns the client." 107 + [client id] 108 + (t/execute client "mutation LikeTrack($id: String!) { likeTrack(id: $id) }" {:id id}) 109 + client) 110 + 111 + (defn unlike-track 112 + "Unlike a track. Returns the client." 113 + [client id] 114 + (t/execute client "mutation UnlikeTrack($id: String!) { unlikeTrack(id: $id) }" {:id id}) 115 + client) 116 + 117 + ;; --------------------------------------------------------------------------- 118 + ;; Search 119 + ;; --------------------------------------------------------------------------- 120 + 121 + (defn search 122 + "Full-text search across artists, albums, tracks. Returns a map with 123 + `:artists :albums :tracks :liked-tracks :liked-albums`." 124 + [client term] 125 + (:search 126 + (t/execute client 127 + (str track-fields album-fields artist-fields 128 + " query Search($term: String!) { 129 + search(term: $term) { 130 + artists { ...ArtistFields } 131 + albums { ...AlbumFields } 132 + tracks { ...TrackFields } 133 + likedTracks { ...TrackFields } 134 + likedAlbums { ...AlbumFields } 135 + } 136 + }") 137 + {:term term}))) 138 + 139 + ;; --------------------------------------------------------------------------- 140 + ;; Library scan 141 + ;; --------------------------------------------------------------------------- 142 + 143 + (defn scan 144 + "Trigger a full rescan of `music_dir`. Returns the client." 145 + [client] 146 + (t/execute client "mutation ScanLibrary { scanLibrary }") 147 + client)
+173
sdk/clojure/src/rockbox/playback.clj
··· 1 + (ns rockbox.playback 2 + "Transport controls and one-call play helpers. 3 + 4 + (-> client 5 + (pb/play-album \"album-id\" {:shuffle true}) 6 + (pb/seek 90000)) 7 + 8 + Action functions return the client so they compose with `->`. Read 9 + functions return data." 10 + (:refer-clojure :exclude [next]) 11 + (:require [rockbox.transport :as t] 12 + [rockbox.types :as types])) 13 + 14 + (def ^:private track-fields 15 + "fragment TrackFields on Track { 16 + id title artist album genre disc trackString yearString 17 + composer comment albumArtist grouping 18 + discnum tracknum layer year bitrate frequency 19 + filesize length elapsed path 20 + albumId artistId genreId albumArt 21 + }") 22 + 23 + ;; --------------------------------------------------------------------------- 24 + ;; State 25 + ;; --------------------------------------------------------------------------- 26 + 27 + (defn raw-status 28 + "Raw integer playback status (firmware enum)." 29 + [client] 30 + (:status (t/execute client "query PlaybackStatus { status }"))) 31 + 32 + (defn status 33 + "Playback status as a keyword: `:stopped`, `:playing`, `:paused`." 34 + [client] 35 + (types/playback-status->keyword (raw-status client))) 36 + 37 + (defn current-track 38 + "Currently playing track as a kebab-case map, or `nil` when stopped." 39 + [client] 40 + (:current-track 41 + (t/execute client (str track-fields 42 + " query CurrentTrack { currentTrack { ...TrackFields } }")))) 43 + 44 + (defn next-track 45 + "Next track in the queue, or `nil`." 46 + [client] 47 + (:next-track 48 + (t/execute client (str track-fields 49 + " query NextTrack { nextTrack { ...TrackFields } }")))) 50 + 51 + (defn file-position 52 + "Byte offset into the current file." 53 + [client] 54 + (:get-file-position (t/execute client "query FilePosition { getFilePosition }"))) 55 + 56 + ;; --------------------------------------------------------------------------- 57 + ;; Transport controls — return the client (pipe-friendly) 58 + ;; --------------------------------------------------------------------------- 59 + 60 + (defn play 61 + "Resume playback from queued position. Optional `:elapsed` and `:offset`." 62 + ([client] (play client {})) 63 + ([client {:keys [elapsed offset] :or {elapsed 0 offset 0}}] 64 + (t/execute client 65 + "mutation Play($elapsed: Long!, $offset: Long!) { play(elapsed: $elapsed, offset: $offset) }" 66 + {:elapsed elapsed :offset offset}) 67 + client)) 68 + 69 + (defn pause [client] 70 + (t/execute client "mutation Pause { pause }") client) 71 + 72 + (defn resume [client] 73 + (t/execute client "mutation Resume { resume }") client) 74 + 75 + (defn next [client] 76 + (t/execute client "mutation Next { next }") client) 77 + 78 + (defn previous [client] 79 + (t/execute client "mutation Previous { previous }") client) 80 + 81 + (defn stop [client] 82 + (t/execute client "mutation Stop { hardStop }") client) 83 + 84 + (defn flush-and-reload 85 + "Force-reload the current queue from disk." 86 + [client] 87 + (t/execute client "mutation FlushReload { flushAndReloadTracks }") client) 88 + 89 + (defn seek 90 + "Seek to an absolute position in milliseconds." 91 + [client position-ms] 92 + (t/execute client 93 + "mutation Seek($newTime: Int!) { fastForwardRewind(newTime: $newTime) }" 94 + {:new-time position-ms}) 95 + client) 96 + 97 + ;; --------------------------------------------------------------------------- 98 + ;; Play helpers 99 + ;; --------------------------------------------------------------------------- 100 + 101 + (defn play-track 102 + "Play a single file by absolute path." 103 + [client path] 104 + (t/execute client 105 + "mutation PlayTrack($path: String!) { playTrack(path: $path) }" 106 + {:path path}) 107 + client) 108 + 109 + (defn play-album 110 + "Play all tracks from an album. Options: `:shuffle` (bool), `:position` (int)." 111 + ([client album-id] (play-album client album-id {})) 112 + ([client album-id opts] 113 + (t/execute client 114 + "mutation PlayAlbum($albumId: String!, $shuffle: Boolean, $position: Int) { 115 + playAlbum(albumId: $albumId, shuffle: $shuffle, position: $position) 116 + }" 117 + (merge {:album-id album-id} opts)) 118 + client)) 119 + 120 + (defn play-artist 121 + "Play all tracks by an artist. Options: `:shuffle`, `:position`." 122 + ([client artist-id] (play-artist client artist-id {})) 123 + ([client artist-id opts] 124 + (t/execute client 125 + "mutation PlayArtist($artistId: String!, $shuffle: Boolean, $position: Int) { 126 + playArtistTracks(artistId: $artistId, shuffle: $shuffle, position: $position) 127 + }" 128 + (merge {:artist-id artist-id} opts)) 129 + client)) 130 + 131 + (defn play-playlist 132 + "Play a saved playlist by id. Options: `:shuffle`, `:position`." 133 + ([client playlist-id] (play-playlist client playlist-id {})) 134 + ([client playlist-id opts] 135 + (t/execute client 136 + "mutation PlayPlaylist($playlistId: String!, $shuffle: Boolean, $position: Int) { 137 + playPlaylist(playlistId: $playlistId, shuffle: $shuffle, position: $position) 138 + }" 139 + (merge {:playlist-id playlist-id} opts)) 140 + client)) 141 + 142 + (defn play-directory 143 + "Play every file under a directory. Options: `:recurse`, `:shuffle`, `:position`." 144 + ([client path] (play-directory client path {})) 145 + ([client path opts] 146 + (t/execute client 147 + "mutation PlayDirectory($path: String!, $recurse: Boolean, $shuffle: Boolean, $position: Int) { 148 + playDirectory(path: $path, recurse: $recurse, shuffle: $shuffle, position: $position) 149 + }" 150 + (merge {:path path} opts)) 151 + client)) 152 + 153 + (defn play-liked-tracks 154 + "Play every liked track. Options: `:shuffle`, `:position`." 155 + ([client] (play-liked-tracks client {})) 156 + ([client opts] 157 + (t/execute client 158 + "mutation PlayLikedTracks($shuffle: Boolean, $position: Int) { 159 + playLikedTracks(shuffle: $shuffle, position: $position) 160 + }" 161 + opts) 162 + client)) 163 + 164 + (defn play-all-tracks 165 + "Play the entire library. Almost always used with `:shuffle true`." 166 + ([client] (play-all-tracks client {})) 167 + ([client opts] 168 + (t/execute client 169 + "mutation PlayAllTracks($shuffle: Boolean, $position: Int) { 170 + playAllTracks(shuffle: $shuffle, position: $position) 171 + }" 172 + opts) 173 + client))
+130
sdk/clojure/src/rockbox/playlist.clj
··· 1 + (ns rockbox.playlist 2 + "Live playback queue management — what's currently playing and what's 3 + queued up next. For persistent named playlists see `rockbox.saved-playlists`." 4 + (:refer-clojure :exclude [shuffle]) 5 + (:require [rockbox.transport :as t] 6 + [rockbox.types :as types])) 7 + 8 + (def ^:private track-fields 9 + "fragment TrackFields on Track { 10 + id title artist album genre disc trackString yearString 11 + composer comment albumArtist grouping 12 + discnum tracknum layer year bitrate frequency 13 + filesize length elapsed path 14 + albumId artistId genreId albumArt 15 + }") 16 + 17 + (defn current 18 + "The active queue: `:tracks :amount :index :max-playlist-size ...`." 19 + [client] 20 + (:playlist-get-current 21 + (t/execute client 22 + (str track-fields 23 + " query CurrentPlaylist { 24 + playlistGetCurrent { 25 + amount index maxPlaylistSize firstIndex 26 + lastInsertPos seed lastShuffledStart 27 + tracks { ...TrackFields } 28 + } 29 + }")))) 30 + 31 + (defn amount 32 + "Number of tracks in the active queue." 33 + [client] 34 + (:playlist-amount (t/execute client "query PlaylistAmount { playlistAmount }"))) 35 + 36 + ;; --------------------------------------------------------------------------- 37 + ;; Queue management 38 + ;; --------------------------------------------------------------------------- 39 + 40 + (defn insert-tracks 41 + "Insert track paths (or IDs) into the queue. 42 + 43 + `position` is a `rockbox.types/insert-position` keyword (`:next :after-current 44 + :last :first`) or the underlying integer; defaults to `:next`. 45 + 46 + Optional `:playlist-id` targets a specific playlist instead of the active queue." 47 + ([client paths] (insert-tracks client paths :next nil)) 48 + ([client paths position] (insert-tracks client paths position nil)) 49 + ([client paths position playlist-id] 50 + (t/execute client 51 + "mutation InsertTracks($playlistId: String, $position: Int!, $tracks: [String!]!) { 52 + insertTracks(playlistId: $playlistId, position: $position, tracks: $tracks) 53 + }" 54 + {:playlist-id playlist-id 55 + :position (types/->insert-position position) 56 + :tracks (vec paths)}) 57 + client)) 58 + 59 + (defn insert-directory 60 + "Insert a directory's contents (recursively) into the queue." 61 + ([client directory] (insert-directory client directory :last nil)) 62 + ([client directory position] (insert-directory client directory position nil)) 63 + ([client directory position playlist-id] 64 + (t/execute client 65 + "mutation InsertDirectory($playlistId: String, $position: Int!, $directory: String!) { 66 + insertDirectory(playlistId: $playlistId, position: $position, directory: $directory) 67 + }" 68 + {:playlist-id playlist-id 69 + :position (types/->insert-position position) 70 + :directory directory}) 71 + client)) 72 + 73 + (defn insert-album 74 + "Append all tracks from an album to the queue." 75 + ([client album-id] (insert-album client album-id :last)) 76 + ([client album-id position] 77 + (t/execute client 78 + "mutation InsertAlbum($albumId: String!, $position: Int!) { 79 + insertAlbum(albumId: $albumId, position: $position) 80 + }" 81 + {:album-id album-id :position (types/->insert-position position)}) 82 + client)) 83 + 84 + (defn remove-track 85 + "Remove the track at queue index `i` (0-based)." 86 + [client i] 87 + (t/execute client "mutation RemoveTrack($index: Int!) { playlistRemoveTrack(index: $index) }" 88 + {:index i}) 89 + client) 90 + 91 + (defn clear 92 + "Remove every track from the queue." 93 + [client] 94 + (t/execute client "mutation ClearPlaylist { playlistRemoveAllTracks }") 95 + client) 96 + 97 + (defn shuffle 98 + "Shuffle the remaining tracks in the queue." 99 + [client] 100 + (t/execute client "mutation ShufflePlaylist { shufflePlaylist }") 101 + client) 102 + 103 + (defn create 104 + "Create and start a new temporary queue from a list of paths. 105 + Replaces the current queue." 106 + [client name paths] 107 + (t/execute client 108 + "mutation CreatePlaylist($name: String!, $tracks: [String!]!) { 109 + playlistCreate(name: $name, tracks: $tracks) 110 + }" 111 + {:name name :tracks (vec paths)}) 112 + client) 113 + 114 + (defn start 115 + "Start playback of the current queue. Options: 116 + `:start-index` (int), `:elapsed` (int), `:offset` (int)." 117 + ([client] (start client {})) 118 + ([client opts] 119 + (t/execute client 120 + "mutation PlaylistStart($startIndex: Int, $elapsed: Int, $offset: Int) { 121 + playlistStart(startIndex: $startIndex, elapsed: $elapsed, offset: $offset) 122 + }" 123 + opts) 124 + client)) 125 + 126 + (defn resume 127 + "Resume from the saved position." 128 + [client] 129 + (t/execute client "mutation PlaylistResume { playlistResume }") 130 + client)
+63
sdk/clojure/src/rockbox/plugin.clj
··· 1 + (ns rockbox.plugin 2 + "Plugin system inspired by Jellyfin's IPlugin and Mopidy's frontend 3 + extensions. A plugin is a plain map with at minimum a `:name` and 4 + `:install` function: 5 + 6 + (def my-scrobbler 7 + {:name \"lastfm-scrobbler\" 8 + :version \"1.0.0\" 9 + :description \"Scrobble plays to Last.fm\" 10 + :install (fn [{:keys [client query events]}] 11 + (events/on client :track-changed 12 + (fn [t] (submit-scrobble t)))) 13 + :uninstall (fn [] (disconnect-lastfm))}) 14 + 15 + Install with `rockbox.core/use-plugin`. The `:install` fn receives a 16 + context map: `{:client client :query query-fn :events events-ns}`." 17 + (:require [rockbox.transport :as transport] 18 + [rockbox.events :as events])) 19 + 20 + (defn- ensure-registry [client] 21 + (or (:plugins client) 22 + (throw (ex-info "Client missing :plugins atom" {:type :rockbox/config})))) 23 + 24 + (defn install 25 + "Install `plugin` into `client`. Throws if a plugin with the same `:name` 26 + is already installed." 27 + [client plugin] 28 + (when-not (and (map? plugin) (string? (:name plugin))) 29 + (throw (ex-info "Plugin must be a map with a :name string" 30 + {:type :rockbox/config :plugin plugin}))) 31 + (let [registry (ensure-registry client)] 32 + (when (contains? @registry (:name plugin)) 33 + (throw (ex-info (str "Plugin \"" (:name plugin) "\" is already installed") 34 + {:type :rockbox/config :name (:name plugin)}))) 35 + (let [ctx {:client client 36 + :query (fn query 37 + ([q] (transport/execute client q)) 38 + ([q vars] (transport/execute client q vars))) 39 + :events {:on (partial events/on client) 40 + :once (partial events/once client) 41 + :off (partial events/off client) 42 + :off-all (partial events/off-all client) 43 + :channel (partial events/channel client) 44 + :close-channel (partial events/close-channel! client)}} 45 + install-fn (:install plugin)] 46 + (when install-fn (install-fn ctx)) 47 + (swap! registry assoc (:name plugin) plugin) 48 + client))) 49 + 50 + (defn uninstall 51 + "Uninstall a plugin by `:name`. Returns the client. Idempotent." 52 + [client plugin-name] 53 + (let [registry (ensure-registry client) 54 + {:keys [uninstall] :as plugin} (get @registry plugin-name)] 55 + (when plugin 56 + (when uninstall (uninstall)) 57 + (swap! registry dissoc plugin-name)) 58 + client)) 59 + 60 + (defn installed 61 + "List installed plugins for `client`." 62 + [client] 63 + (vec (vals @(ensure-registry client))))
+145
sdk/clojure/src/rockbox/saved_playlists.clj
··· 1 + (ns rockbox.saved-playlists 2 + "Persistent named playlists stored in the database, with folder support." 3 + (:refer-clojure :exclude [list get update remove]) 4 + (:require [rockbox.transport :as t])) 5 + 6 + (def ^:private playlist-fields 7 + "id name description image folderId trackCount createdAt updatedAt") 8 + 9 + ;; --------------------------------------------------------------------------- 10 + ;; Read 11 + ;; --------------------------------------------------------------------------- 12 + 13 + (defn list 14 + "List saved playlists, optionally filtered by folder." 15 + ([client] (list client nil)) 16 + ([client folder-id] 17 + (:saved-playlists 18 + (t/execute client 19 + (str "query SavedPlaylists($folderId: String) { 20 + savedPlaylists(folderId: $folderId) { " playlist-fields " } 21 + }") 22 + {:folder-id folder-id})))) 23 + 24 + (defn get 25 + "Single playlist by id, or `nil`." 26 + [client id] 27 + (:saved-playlist 28 + (t/execute client 29 + (str "query SavedPlaylist($id: String!) { 30 + savedPlaylist(id: $id) { " playlist-fields " } 31 + }") 32 + {:id id}))) 33 + 34 + (defn track-ids 35 + "Ordered track ids for a saved playlist." 36 + [client playlist-id] 37 + (:saved-playlist-track-ids 38 + (t/execute client 39 + "query SavedPlaylistTrackIds($playlistId: String!) { 40 + savedPlaylistTrackIds(playlistId: $playlistId) 41 + }" 42 + {:playlist-id playlist-id}))) 43 + 44 + ;; --------------------------------------------------------------------------- 45 + ;; Mutations 46 + ;; --------------------------------------------------------------------------- 47 + 48 + (defn create 49 + "Create a saved playlist. `input` keys: `:name` (required), `:description`, 50 + `:image`, `:folder-id`, `:track-ids`. Returns the new playlist." 51 + [client {:keys [name] :as input}] 52 + (when-not name 53 + (throw (ex-info ":name is required" {:type :rockbox/config :input input}))) 54 + (:create-saved-playlist 55 + (t/execute client 56 + (str "mutation CreateSavedPlaylist( 57 + $name: String!, $description: String, $image: String, 58 + $folderId: String, $trackIds: [String!] 59 + ) { 60 + createSavedPlaylist( 61 + name: $name, description: $description, image: $image, 62 + folderId: $folderId, trackIds: $trackIds 63 + ) { " playlist-fields " } 64 + }") 65 + input))) 66 + 67 + (defn update 68 + "Update a saved playlist's metadata. `input`: `:name` (required), 69 + `:description`, `:image`, `:folder-id`. Returns the client." 70 + [client id {:keys [name] :as input}] 71 + (when-not name 72 + (throw (ex-info ":name is required" {:type :rockbox/config :input input}))) 73 + (t/execute client 74 + "mutation UpdateSavedPlaylist( 75 + $id: String!, $name: String!, $description: String, $image: String, $folderId: String 76 + ) { 77 + updateSavedPlaylist( 78 + id: $id, name: $name, description: $description, image: $image, folderId: $folderId 79 + ) 80 + }" 81 + (assoc input :id id)) 82 + client) 83 + 84 + (defn delete 85 + "Delete a saved playlist." 86 + [client id] 87 + (t/execute client "mutation DeleteSavedPlaylist($id: String!) { deleteSavedPlaylist(id: $id) }" 88 + {:id id}) 89 + client) 90 + 91 + (defn add-tracks 92 + "Append tracks (by id) to a saved playlist." 93 + [client playlist-id track-ids] 94 + (t/execute client 95 + "mutation AddTracksToSavedPlaylist($playlistId: String!, $trackIds: [String!]!) { 96 + addTracksToSavedPlaylist(playlistId: $playlistId, trackIds: $trackIds) 97 + }" 98 + {:playlist-id playlist-id :track-ids (vec track-ids)}) 99 + client) 100 + 101 + (defn remove-track 102 + "Remove a single track from a saved playlist." 103 + [client playlist-id track-id] 104 + (t/execute client 105 + "mutation RemoveTrackFromSavedPlaylist($playlistId: String!, $trackId: String!) { 106 + removeTrackFromSavedPlaylist(playlistId: $playlistId, trackId: $trackId) 107 + }" 108 + {:playlist-id playlist-id :track-id track-id}) 109 + client) 110 + 111 + (defn play 112 + "Load a saved playlist into the queue and start playing." 113 + [client playlist-id] 114 + (t/execute client 115 + "mutation PlaySavedPlaylist($playlistId: String!) { playSavedPlaylist(playlistId: $playlistId) }" 116 + {:playlist-id playlist-id}) 117 + client) 118 + 119 + ;; --------------------------------------------------------------------------- 120 + ;; Folders 121 + ;; --------------------------------------------------------------------------- 122 + 123 + (defn folders 124 + "List playlist folders." 125 + [client] 126 + (:playlist-folders 127 + (t/execute client 128 + "query PlaylistFolders { playlistFolders { id name createdAt updatedAt } }"))) 129 + 130 + (defn create-folder 131 + "Create a new folder. Returns the folder map." 132 + [client name] 133 + (:create-playlist-folder 134 + (t/execute client 135 + "mutation CreatePlaylistFolder($name: String!) { 136 + createPlaylistFolder(name: $name) { id name createdAt updatedAt } 137 + }" 138 + {:name name}))) 139 + 140 + (defn delete-folder 141 + "Delete a folder by id." 142 + [client id] 143 + (t/execute client "mutation DeletePlaylistFolder($id: String!) { deletePlaylistFolder(id: $id) }" 144 + {:id id}) 145 + client)
+34
sdk/clojure/src/rockbox/settings.clj
··· 1 + (ns rockbox.settings 2 + "Read and write global rockboxd settings (volume, EQ, repeat mode, ...)." 3 + (:refer-clojure :exclude [get]) 4 + (:require [rockbox.transport :as t])) 5 + 6 + (def ^:private settings-fields 7 + "musicDir volume balance bass treble channelConfig stereoWidth 8 + eqEnabled eqPrecut 9 + eqBandSettings { cutoff q gain } 10 + replaygainSettings { noclip type preamp } 11 + compressorSettings { threshold makeupGain ratio knee releaseTime attackTime } 12 + crossfadeEnabled crossfadeFadeInDelay crossfadeFadeInDuration 13 + crossfadeFadeOutDelay crossfadeFadeOutDuration crossfadeFadeOutMixmode 14 + crossfeedEnabled crossfeedDirectGain crossfeedCrossGain 15 + crossfeedHfAttenuation crossfeedHfCutoff 16 + repeatMode singleMode partyMode shuffle playerName") 17 + 18 + (defn get 19 + "All current settings as a kebab-case map." 20 + [client] 21 + (:global-settings 22 + (t/execute client (str "query GlobalSettings { globalSettings { " settings-fields " } }")))) 23 + 24 + (defn save 25 + "Persist a partial settings map. Only the keys you pass are written. 26 + Keys may be kebab-case keywords (e.g. `:eq-enabled`, `:repeat-mode`). 27 + 28 + Nested settings (`:eq-band-settings`, `:replaygain-settings`, 29 + `:compressor-settings`) accept the obvious shapes; see README for examples." 30 + [client settings] 31 + (t/execute client 32 + "mutation SaveSettings($settings: NewGlobalSettings!) { saveSettings(settings: $settings) }" 33 + {:settings settings}) 34 + client)
+121
sdk/clojure/src/rockbox/smart_playlists.clj
··· 1 + (ns rockbox.smart-playlists 2 + "Smart (rule-based) playlists and listening stats." 3 + (:refer-clojure :exclude [list get update]) 4 + (:require [clojure.data.json :as json] 5 + [rockbox.transport :as t])) 6 + 7 + (def ^:private smart-fields 8 + "id name description image folderId isSystem rules createdAt updatedAt") 9 + 10 + ;; --------------------------------------------------------------------------- 11 + ;; Read 12 + ;; --------------------------------------------------------------------------- 13 + 14 + (defn list [client] 15 + (:smart-playlists 16 + (t/execute client 17 + (str "query SmartPlaylists { smartPlaylists { " smart-fields " } }")))) 18 + 19 + (defn get [client id] 20 + (:smart-playlist 21 + (t/execute client 22 + (str "query SmartPlaylist($id: String!) { smartPlaylist(id: $id) { " smart-fields " } }") 23 + {:id id}))) 24 + 25 + (defn track-ids 26 + "Resolve a smart playlist to the matching track ids right now." 27 + [client id] 28 + (:smart-playlist-track-ids 29 + (t/execute client 30 + "query SmartPlaylistTrackIds($id: String!) { smartPlaylistTrackIds(id: $id) }" 31 + {:id id}))) 32 + 33 + ;; --------------------------------------------------------------------------- 34 + ;; Mutations — `:rules` may be a string or any data; data is JSON-encoded 35 + ;; --------------------------------------------------------------------------- 36 + 37 + (defn- ->rules-string [rules] 38 + (cond 39 + (string? rules) rules 40 + (nil? rules) nil 41 + :else (json/write-str rules))) 42 + 43 + (defn create 44 + "Create a smart playlist. `input` keys: `:name` (required), `:rules` 45 + (required — string or data), `:description`, `:image`, `:folder-id`." 46 + [client {:keys [name rules] :as input}] 47 + (when-not (and name rules) 48 + (throw (ex-info ":name and :rules are required" 49 + {:type :rockbox/config :input input}))) 50 + (:create-smart-playlist 51 + (t/execute client 52 + (str "mutation CreateSmartPlaylist( 53 + $name: String!, $rules: String!, $description: String, 54 + $image: String, $folderId: String 55 + ) { 56 + createSmartPlaylist( 57 + name: $name, rules: $rules, description: $description, 58 + image: $image, folderId: $folderId 59 + ) { " smart-fields " } 60 + }") 61 + (assoc input :rules (->rules-string rules))))) 62 + 63 + (defn update 64 + "Update a smart playlist. `input` keys: `:name`, `:rules`, ..." 65 + [client id {:keys [name rules] :as input}] 66 + (when-not (and name rules) 67 + (throw (ex-info ":name and :rules are required" 68 + {:type :rockbox/config :input input}))) 69 + (t/execute client 70 + "mutation UpdateSmartPlaylist( 71 + $id: String!, $name: String!, $rules: String!, 72 + $description: String, $image: String, $folderId: String 73 + ) { 74 + updateSmartPlaylist( 75 + id: $id, name: $name, rules: $rules, description: $description, 76 + image: $image, folderId: $folderId 77 + ) 78 + }" 79 + (assoc input :id id :rules (->rules-string rules))) 80 + client) 81 + 82 + (defn delete [client id] 83 + (t/execute client "mutation DeleteSmartPlaylist($id: String!) { deleteSmartPlaylist(id: $id) }" 84 + {:id id}) 85 + client) 86 + 87 + (defn play [client id] 88 + (t/execute client 89 + "mutation PlaySmartPlaylist($id: String!) { playSmartPlaylist(id: $id) }" 90 + {:id id}) 91 + client) 92 + 93 + ;; --------------------------------------------------------------------------- 94 + ;; Listening stats 95 + ;; --------------------------------------------------------------------------- 96 + 97 + (defn track-stats 98 + "Listening stats for a track, or `nil`." 99 + [client track-id] 100 + (:track-stats 101 + (t/execute client 102 + "query TrackStats($trackId: String!) { 103 + trackStats(trackId: $trackId) { 104 + trackId playCount skipCount lastPlayed lastSkipped updatedAt 105 + } 106 + }" 107 + {:track-id track-id}))) 108 + 109 + (defn record-played 110 + "Record that a track was played." 111 + [client track-id] 112 + (t/execute client "mutation RecordTrackPlayed($trackId: String!) { recordTrackPlayed(trackId: $trackId) }" 113 + {:track-id track-id}) 114 + client) 115 + 116 + (defn record-skipped 117 + "Record that a track was skipped." 118 + [client track-id] 119 + (t/execute client "mutation RecordTrackSkipped($trackId: String!) { recordTrackSkipped(trackId: $trackId) }" 120 + {:track-id track-id}) 121 + client)
+24
sdk/clojure/src/rockbox/sound.clj
··· 1 + (ns rockbox.sound 2 + "Volume control. Values are in firmware-defined steps, not absolute dB." 3 + (:require [rockbox.transport :as t])) 4 + 5 + (defn volume 6 + "Current volume info: `{:volume :min :max}`." 7 + [client] 8 + (:volume (t/execute client "query Volume { volume { volume min max } }"))) 9 + 10 + (defn adjust-volume 11 + "Change volume by `steps` (positive = louder, negative = quieter). 12 + Returns the new raw volume value." 13 + [client steps] 14 + (:adjust-volume 15 + (t/execute client "mutation AdjustVolume($steps: Int!) { adjustVolume(steps: $steps) }" 16 + {:steps steps}))) 17 + 18 + (defn volume-up 19 + "Step volume up by one. Returns the new raw volume." 20 + [client] (adjust-volume client 1)) 21 + 22 + (defn volume-down 23 + "Step volume down by one. Returns the new raw volume." 24 + [client] (adjust-volume client -1))
+21
sdk/clojure/src/rockbox/system.clj
··· 1 + (ns rockbox.system 2 + "System info — daemon version and global runtime status." 3 + (:require [rockbox.transport :as t])) 4 + 5 + (defn version 6 + "rockboxd version string." 7 + [client] 8 + (:rockbox-version (t/execute client "query Version { rockboxVersion }"))) 9 + 10 + (defn status 11 + "Global runtime status: `:runtime :topruntime :resume-index ...`." 12 + [client] 13 + (:global-status 14 + (t/execute client 15 + "query GlobalStatus { 16 + globalStatus { 17 + resumeIndex resumeCrc32 resumeElapsed resumeOffset 18 + runtime topruntime dircacheSize 19 + lastScreen viewerIconCount lastVolumeChange 20 + } 21 + }")))
+106
sdk/clojure/src/rockbox/transport.clj
··· 1 + (ns rockbox.transport 2 + "HTTP transport for GraphQL queries — built on `java.net.http.HttpClient` 3 + so the SDK has no third-party HTTP dependency. 4 + 5 + You normally don't call this directly; use `rockbox.core/query` or one of 6 + the domain APIs (`rockbox.playback`, `rockbox.library`, ...). Exposed for 7 + plugin authors and advanced consumers who need a stable hook." 8 + (:require [clojure.data.json :as json] 9 + [rockbox.errors :as err] 10 + [rockbox.util :as util]) 11 + (:import (java.net URI) 12 + (java.net.http HttpClient HttpClient$Redirect 13 + HttpRequest HttpResponse$BodyHandlers 14 + HttpRequest$BodyPublishers) 15 + (java.time Duration))) 16 + 17 + ;; --------------------------------------------------------------------------- 18 + ;; HttpClient — one cached instance per JVM (thread-safe, reusable) 19 + ;; --------------------------------------------------------------------------- 20 + 21 + (defonce ^:private default-client 22 + (delay 23 + (-> (HttpClient/newBuilder) 24 + (.connectTimeout (Duration/ofSeconds 10)) 25 + (.followRedirects HttpClient$Redirect/NORMAL) 26 + (.build)))) 27 + 28 + (defn- http-client ^HttpClient [client] 29 + (or (:http-client client) @default-client)) 30 + 31 + ;; --------------------------------------------------------------------------- 32 + ;; Request building 33 + ;; --------------------------------------------------------------------------- 34 + 35 + (defn- ->headers ^"[Ljava.lang.String;" [headers] 36 + (let [base ["Content-Type" "application/json" 37 + "Accept" "application/json"] 38 + all (into base 39 + (mapcat (fn [[k v]] [(name k) (str v)])) 40 + headers)] 41 + (into-array String all))) 42 + 43 + (defn- build-request ^HttpRequest [{:keys [http-url timeout-ms headers]} body-json] 44 + (let [b (-> (HttpRequest/newBuilder) 45 + (.uri (URI/create http-url)) 46 + (.timeout (Duration/ofMillis (long (or timeout-ms 15000)))) 47 + (.POST (HttpRequest$BodyPublishers/ofString body-json)))] 48 + (when (seq headers) 49 + (.headers b (->headers headers))) 50 + (.header b "Content-Type" "application/json") 51 + (.header b "Accept" "application/json") 52 + (.build b))) 53 + 54 + (defn- send-request [client req] 55 + (try 56 + (.send (http-client client) req (HttpResponse$BodyHandlers/ofString)) 57 + (catch java.net.ConnectException e 58 + (throw (err/network-error 59 + (str "Failed to reach Rockbox at " (:http-url client)) e))) 60 + (catch java.net.http.HttpConnectTimeoutException e 61 + (throw (err/network-error "Connect timeout" e))) 62 + (catch java.net.http.HttpTimeoutException e 63 + (throw (err/network-error "HTTP timeout" e))) 64 + (catch java.io.IOException e 65 + (throw (err/network-error (.getMessage e) e))))) 66 + 67 + ;; --------------------------------------------------------------------------- 68 + ;; Public API 69 + ;; --------------------------------------------------------------------------- 70 + 71 + (defn execute 72 + "Execute a GraphQL query/mutation and return the parsed `data` map (with 73 + kebab-case keyword keys). 74 + 75 + `variables` may be a Clojure map with kebab-case keys; they are converted to 76 + camelCase strings before sending. Returns the kebabized `data` payload, or 77 + throws an `ex-info` of type `:rockbox/network` or `:rockbox/graphql`." 78 + ([client query] (execute client query nil)) 79 + ([client query variables] 80 + (let [vars (some-> variables util/drop-nils not-empty util/camelize-keys) 81 + payload (cond-> {"query" query} 82 + vars (assoc "variables" vars)) 83 + body (json/write-str payload) 84 + req (build-request client body) 85 + resp (send-request client req) 86 + status (.statusCode ^java.net.http.HttpResponse resp) 87 + body-str (.body ^java.net.http.HttpResponse resp)] 88 + (when-not (<= 200 status 299) 89 + (throw (err/network-error (str "HTTP " status " from " (:http-url client))))) 90 + (let [parsed (try 91 + (json/read-str body-str) 92 + (catch Exception e 93 + (throw (err/network-error 94 + (str "Invalid JSON response: " (.getMessage e)) e)))) 95 + errors (get parsed "errors")] 96 + (when (and errors (seq errors)) 97 + (throw (err/graphql-error (mapv util/kebabize-keys errors)))) 98 + (-> (get parsed "data") util/kebabize-keys))))) 99 + 100 + (defn execute-field 101 + "Convenience wrapper: execute the query, then pluck a single top-level 102 + field from the response. Most domain APIs use this for one-line bodies." 103 + ([client query field] 104 + (get (execute client query) field)) 105 + ([client query variables field] 106 + (get (execute client query variables) field)))
+118
sdk/clojure/src/rockbox/types.clj
··· 1 + (ns rockbox.types 2 + "Enum constants and tiny helpers for Rockbox values that come back as ints 3 + or attribute bitmasks. 4 + 5 + Constants are exposed both as namespaced keywords (idiomatic in Clojure) 6 + and as the raw integers the firmware uses. Use whichever fits your code: 7 + 8 + (require '[rockbox.types :as t]) 9 + 10 + (= (:status track) (t/playback-status :playing)) ; via keyword 11 + (= (:status track) t/playing) ; via raw int alias 12 + ") 13 + 14 + ;; --------------------------------------------------------------------------- 15 + ;; Playback status (firmware enum) 16 + ;; --------------------------------------------------------------------------- 17 + 18 + (def playback-status 19 + "Map :stopped/:playing/:paused -> firmware integer." 20 + {:stopped 0 21 + :playing 1 22 + :paused 3}) 23 + 24 + (def playback-status->keyword 25 + "Reverse lookup — firmware integer -> keyword." 26 + (zipmap (vals playback-status) (keys playback-status))) 27 + 28 + (def stopped 0) 29 + (def playing 1) 30 + (def paused 3) 31 + 32 + ;; --------------------------------------------------------------------------- 33 + ;; Repeat mode 34 + ;; --------------------------------------------------------------------------- 35 + 36 + (def repeat-mode 37 + {:off 0 38 + :all 1 39 + :one 2 40 + :shuffle 3 41 + :ab-repeat 4}) 42 + 43 + (def repeat-mode->keyword 44 + (zipmap (vals repeat-mode) (keys repeat-mode))) 45 + 46 + ;; --------------------------------------------------------------------------- 47 + ;; Channel config 48 + ;; --------------------------------------------------------------------------- 49 + 50 + (def channel-config 51 + {:stereo 0 52 + :stereo-narrow 1 53 + :mono 2 54 + :left-mix 3 55 + :right-mix 4 56 + :karaoke 5}) 57 + 58 + ;; --------------------------------------------------------------------------- 59 + ;; ReplayGain type 60 + ;; --------------------------------------------------------------------------- 61 + 62 + (def replaygain-type 63 + {:track 0 64 + :album 1 65 + :shuffle 2}) 66 + 67 + ;; --------------------------------------------------------------------------- 68 + ;; Insert position (queue management) 69 + ;; --------------------------------------------------------------------------- 70 + 71 + (def insert-position 72 + "Where to insert tracks into the active queue. 73 + 74 + :next — after the currently playing track 75 + :after-current — after the last manually inserted track 76 + :last — at the end of the queue 77 + :first — replace the entire queue" 78 + {:next 0 79 + :after-current 1 80 + :last 2 81 + :first 3}) 82 + 83 + (defn ->insert-position 84 + "Coerce a keyword or int into the firmware integer." 85 + [x] 86 + (if (integer? x) x (get insert-position x 0))) 87 + 88 + ;; --------------------------------------------------------------------------- 89 + ;; Filesystem entry helpers 90 + ;; --------------------------------------------------------------------------- 91 + 92 + (def ^:const directory-attr-bit 0x10) 93 + 94 + (defn directory? 95 + "True if the entry is a directory (bit 4 of `:attr`)." 96 + [entry] 97 + (not (zero? (bit-and (or (:attr entry) 0) directory-attr-bit)))) 98 + 99 + (defn file? 100 + "True if the entry is a file (i.e. not a directory)." 101 + [entry] 102 + (not (directory? entry))) 103 + 104 + ;; --------------------------------------------------------------------------- 105 + ;; Duration formatting 106 + ;; --------------------------------------------------------------------------- 107 + 108 + (defn format-ms 109 + "Format a millisecond duration as `M:SS`. 110 + 111 + (format-ms 75000) ;=> \"1:15\"" 112 + [ms] 113 + (if (and (number? ms) (not (neg? ms))) 114 + (let [total (long (quot ms 1000)) 115 + minutes (quot total 60) 116 + seconds (rem total 60)] 117 + (format "%d:%02d" minutes seconds)) 118 + "0:00"))
+60
sdk/clojure/src/rockbox/util.clj
··· 1 + (ns rockbox.util 2 + "Internal helpers — case conversion between Clojure (kebab-case keywords) 3 + and GraphQL (camelCase strings)." 4 + (:require [clojure.string :as str])) 5 + 6 + ;; --------------------------------------------------------------------------- 7 + ;; Case conversion 8 + ;; --------------------------------------------------------------------------- 9 + 10 + (defn kebab->camel 11 + "kebab-case-name -> kebabCaseName. Pass-through for strings without dashes." 12 + ^String [^String s] 13 + (let [parts (str/split s #"-")] 14 + (if (= 1 (count parts)) 15 + s 16 + (apply str (first parts) (map str/capitalize (rest parts)))))) 17 + 18 + (defn camel->kebab 19 + "camelCaseName -> kebab-case-name." 20 + ^String [^String s] 21 + (-> s 22 + (str/replace #"([a-z0-9])([A-Z])" "$1-$2") 23 + (str/lower-case))) 24 + 25 + (defn- key->camel-string [k] 26 + (cond 27 + (keyword? k) (kebab->camel (name k)) 28 + (string? k) (kebab->camel k) 29 + :else k)) 30 + 31 + (defn- key->kebab-keyword [k] 32 + (cond 33 + (keyword? k) (keyword (camel->kebab (name k))) 34 + (string? k) (keyword (camel->kebab k)) 35 + :else k)) 36 + 37 + (defn camelize-keys 38 + "Recursively convert all map keys to camelCase strings (for GraphQL variables)." 39 + [x] 40 + (cond 41 + (map? x) (into {} (map (fn [[k v]] [(key->camel-string k) (camelize-keys v)]) x)) 42 + (sequential? x) (mapv camelize-keys x) 43 + :else x)) 44 + 45 + (defn kebabize-keys 46 + "Recursively convert all map keys to kebab-case keywords (for response data)." 47 + [x] 48 + (cond 49 + (map? x) (into {} (map (fn [[k v]] [(key->kebab-keyword k) (kebabize-keys v)]) x)) 50 + (sequential? x) (mapv kebabize-keys x) 51 + :else x)) 52 + 53 + ;; --------------------------------------------------------------------------- 54 + ;; GraphQL helpers 55 + ;; --------------------------------------------------------------------------- 56 + 57 + (defn drop-nils 58 + "Strip nil values from a map. Useful when forwarding optional GraphQL args." 59 + [m] 60 + (into {} (remove (comp nil? val) m)))
+235
sdk/clojure/src/rockbox/ws.clj
··· 1 + (ns rockbox.ws 2 + "WebSocket transport implementing the `graphql-ws` subprotocol 3 + (https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md). 4 + 5 + Uses `java.net.http.WebSocket` so there is no third-party WS dependency. 6 + Auto-reconnects with exponential backoff up to 30 s. Fires `:ws-open`, 7 + `:ws-close`, `:ws-error` events through the SDK's event registry. 8 + 9 + This is an internal namespace — call `rockbox.core/connect` instead." 10 + (:require [clojure.data.json :as json] 11 + [rockbox.events :as events] 12 + [rockbox.util :as util]) 13 + (:import (java.net URI) 14 + (java.net.http HttpClient WebSocket WebSocket$Builder 15 + WebSocket$Listener) 16 + (java.time Duration) 17 + (java.util.concurrent CompletableFuture 18 + ConcurrentHashMap) 19 + (java.util.concurrent.atomic AtomicLong AtomicBoolean))) 20 + 21 + ;; --------------------------------------------------------------------------- 22 + ;; Connection record 23 + ;; --------------------------------------------------------------------------- 24 + ;; Keys: 25 + ;; :url — ws://host:port/graphql 26 + ;; :client — the rockbox client value (for emitting events) 27 + ;; :ws — atom holding the current WebSocket instance 28 + ;; :alive? — AtomicBoolean — true while the user wants the conn open 29 + ;; :next-id — AtomicLong, monotonic op id 30 + ;; :subscriptions — ConcurrentHashMap<id, {:query :variables :sink}> 31 + ;; `sink` is `{:next fn :error fn :complete fn}` 32 + ;; :ack? — atom: has the server sent connection_ack? 33 + ;; :buf — atom (StringBuilder) for assembling fragmented frames 34 + 35 + (defn- new-connection [url client] 36 + {:url url 37 + :client client 38 + :ws (atom nil) 39 + :alive? (AtomicBoolean. true) 40 + :next-id (AtomicLong. 1) 41 + :subscriptions (ConcurrentHashMap.) 42 + :ack? (atom false) 43 + :buf (atom (StringBuilder.))}) 44 + 45 + ;; --------------------------------------------------------------------------- 46 + ;; Sending 47 + ;; --------------------------------------------------------------------------- 48 + 49 + (defn- send-text! [conn msg] 50 + (when-let [^WebSocket ws @(:ws conn)] 51 + (try 52 + (.sendText ws (json/write-str msg) true) 53 + (catch Throwable t 54 + (events/emit (:client conn) :ws-error t))))) 55 + 56 + (declare connect!) 57 + 58 + (defn- emit-buffered-subscriptions! [conn] 59 + (doseq [[id sub] (.entrySet ^ConcurrentHashMap (:subscriptions conn))] 60 + (let [k (.getKey ^java.util.Map$Entry id) 61 + v (.getValue ^java.util.Map$Entry id)] 62 + ;; Re-send subscribe frames after reconnect 63 + (send-text! conn 64 + {"id" k 65 + "type" "subscribe" 66 + "payload" 67 + (cond-> {"query" (:query v)} 68 + (some? (:variables v)) 69 + (assoc "variables" (util/camelize-keys (:variables v))))})))) 70 + 71 + ;; --------------------------------------------------------------------------- 72 + ;; Listener — handles all incoming frames + lifecycle events 73 + ;; --------------------------------------------------------------------------- 74 + 75 + (defn- handle-message [conn ^String text] 76 + (let [msg (try (json/read-str text) (catch Exception _ nil)) 77 + msg-type (get msg "type")] 78 + (case msg-type 79 + "connection_ack" 80 + (do (reset! (:ack? conn) true) 81 + (events/emit (:client conn) :ws-open nil) 82 + (emit-buffered-subscriptions! conn)) 83 + 84 + "ping" 85 + (send-text! conn {"type" "pong"}) 86 + 87 + "pong" nil 88 + 89 + ("next" "data") 90 + (let [id (get msg "id") 91 + data (some-> (get-in msg ["payload" "data"]) util/kebabize-keys) 92 + sink (some-> ^ConcurrentHashMap (:subscriptions conn) (.get id) :sink)] 93 + (when sink ((:next sink) {:data data}))) 94 + 95 + "error" 96 + (let [id (get msg "id") 97 + errs (get-in msg ["payload"]) 98 + sink (some-> ^ConcurrentHashMap (:subscriptions conn) (.get id) :sink)] 99 + (when sink ((:error sink) errs))) 100 + 101 + "complete" 102 + (let [id (get msg "id") 103 + sink (some-> ^ConcurrentHashMap (:subscriptions conn) (.get id) :sink)] 104 + (when sink ((:complete sink))) 105 + (.remove ^ConcurrentHashMap (:subscriptions conn) id)) 106 + 107 + ;; Ignore anything we don't recognise 108 + nil))) 109 + 110 + (defn- backoff-ms [attempt] 111 + (min 30000 (long (* 1000 (Math/pow 2 (min attempt 10)))))) 112 + 113 + (defn- schedule-reconnect [conn attempt] 114 + (when (.get ^AtomicBoolean (:alive? conn)) 115 + (let [delay (backoff-ms attempt)] 116 + (future 117 + (Thread/sleep delay) 118 + (when (.get ^AtomicBoolean (:alive? conn)) 119 + (try (connect! conn (inc attempt)) 120 + (catch Throwable t 121 + (events/emit (:client conn) :ws-error t) 122 + (schedule-reconnect conn (inc attempt))))))))) 123 + 124 + (defn- ^WebSocket$Listener make-listener [conn ^"[I" reconnect-attempt] 125 + (reify WebSocket$Listener 126 + (onOpen [_ ws] 127 + (.request ^WebSocket ws 1) 128 + (aset reconnect-attempt 0 0) 129 + ;; graphql-ws handshake 130 + (send-text! conn {"type" "connection_init"})) 131 + 132 + (onText [_ ws data last?] 133 + (let [^StringBuilder sb @(:buf conn)] 134 + (.append sb (str data)) 135 + (when last? 136 + (let [text (.toString sb)] 137 + (reset! (:buf conn) (StringBuilder.)) 138 + (handle-message conn text)))) 139 + (.request ^WebSocket ws 1) 140 + nil) 141 + 142 + (onBinary [_ ws _data _last?] 143 + (.request ^WebSocket ws 1) 144 + nil) 145 + 146 + (onPing [_ ws msg] 147 + (.sendPong ^WebSocket ws msg)) 148 + 149 + (onPong [_ ws _msg] 150 + (.request ^WebSocket ws 1) 151 + nil) 152 + 153 + (onClose [_ _ws _code _reason] 154 + (reset! (:ack? conn) false) 155 + (reset! (:ws conn) nil) 156 + (events/emit (:client conn) :ws-close nil) 157 + (when (.get ^AtomicBoolean (:alive? conn)) 158 + (schedule-reconnect conn (aget reconnect-attempt 0))) 159 + nil) 160 + 161 + (onError [_ _ws err] 162 + (reset! (:ack? conn) false) 163 + (events/emit (:client conn) :ws-error err) 164 + (aset reconnect-attempt 0 (inc (aget reconnect-attempt 0))) 165 + (when (.get ^AtomicBoolean (:alive? conn)) 166 + (schedule-reconnect conn (aget reconnect-attempt 0)))))) 167 + 168 + ;; --------------------------------------------------------------------------- 169 + ;; Connect / disconnect 170 + ;; --------------------------------------------------------------------------- 171 + 172 + (defn- connect! 173 + ([conn] (connect! conn 0)) 174 + ([conn attempt] 175 + (let [http (HttpClient/newHttpClient) 176 + attempts-arr (int-array 1 attempt) 177 + listener (make-listener conn attempts-arr) 178 + ^CompletableFuture cf 179 + (-> (.newWebSocketBuilder http) 180 + (.subprotocols "graphql-transport-ws" (into-array String [])) 181 + (.connectTimeout (Duration/ofSeconds 15)) 182 + (.buildAsync (URI/create (:url conn)) listener))] 183 + (-> cf 184 + (.thenAccept (reify java.util.function.Consumer 185 + (accept [_ ws] 186 + (reset! (:ws conn) ws)))) 187 + (.exceptionally (reify java.util.function.Function 188 + (apply [_ throwable] 189 + (events/emit (:client conn) :ws-error throwable) 190 + (schedule-reconnect conn (inc attempt)) 191 + nil))))) 192 + conn)) 193 + 194 + (defn open 195 + "Open a new WebSocket connection. Returns a `connection` value that you 196 + pass to `subscribe` and `close`." 197 + [client ws-url] 198 + (let [conn (new-connection ws-url client)] 199 + (connect! conn 0) 200 + conn)) 201 + 202 + (defn close 203 + "Tear down the WebSocket connection. Idempotent." 204 + [conn] 205 + (when conn 206 + (.set ^AtomicBoolean (:alive? conn) false) 207 + (when-let [^WebSocket ws @(:ws conn)] 208 + (try (.sendClose ws WebSocket/NORMAL_CLOSURE "bye") 209 + (catch Throwable _ nil))) 210 + (reset! (:ws conn) nil) 211 + (.clear ^ConcurrentHashMap (:subscriptions conn))) 212 + nil) 213 + 214 + ;; --------------------------------------------------------------------------- 215 + ;; Subscriptions 216 + ;; --------------------------------------------------------------------------- 217 + 218 + (defn subscribe 219 + "Start a GraphQL subscription. `sink` is a map with `:next`, `:error`, 220 + `:complete` keys. Returns a 0-arity unsubscribe fn." 221 + [conn query variables sink] 222 + (let [id (str (.getAndIncrement ^AtomicLong (:next-id conn))) 223 + full {:query query :variables variables :sink sink}] 224 + (.put ^ConcurrentHashMap (:subscriptions conn) id full) 225 + (when @(:ack? conn) 226 + (send-text! conn 227 + {"id" id 228 + "type" "subscribe" 229 + "payload" 230 + (cond-> {"query" query} 231 + (some? variables) 232 + (assoc "variables" (util/camelize-keys variables)))})) 233 + (fn unsubscribe [] 234 + (when (.remove ^ConcurrentHashMap (:subscriptions conn) id) 235 + (send-text! conn {"id" id "type" "complete"})))))
+4
sdk/elixir/.formatter.exs
··· 1 + # Used by "mix format" 2 + [ 3 + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 + ]
+24
sdk/elixir/.gitignore
··· 1 + # The directory Mix will write compiled artifacts to. 2 + /_build/ 3 + 4 + # If you run "mix test --cover", coverage assets end up here. 5 + /cover/ 6 + 7 + # The directory Mix downloads your dependencies sources to. 8 + /deps/ 9 + 10 + # Where third-party dependencies like ExDoc output generated docs. 11 + /doc/ 12 + 13 + # Temporary files, for example, from tests. 14 + /tmp/ 15 + 16 + # If the VM crashes, it generates a dump, let's ignore it too. 17 + erl_crash.dump 18 + 19 + # Also ignore archive artifacts (built via "mix archive.build"). 20 + *.ez 21 + 22 + # Ignore package tarball (built via "mix hex.build"). 23 + rockbox_ex-*.tar 24 +
+21
sdk/elixir/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app> 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+675
sdk/elixir/README.md
··· 1 + # rockbox_ex 2 + 3 + Idiomatic Elixir SDK for [Rockbox Zig](https://github.com/tsirysndr/rockbox-zig) — a fully typed 4 + GraphQL client for `rockboxd` with real-time WebSocket subscriptions, a 5 + plugin behaviour, and a builder DSL for smart playlists. 6 + 7 + - **Pipe-friendly** — every API function takes the client as its first arg. 8 + - **Builder-friendly** — smart-playlist rules and partial settings updates 9 + compose with `|>`. 10 + - **Tagged tuples or bangs** — `name/N → {:ok, value} | {:error, exception}`, 11 + with a matching `name!/N` that raises. 12 + - **Real-time events as messages** — `Rockbox.subscribe(:track_changed)` and 13 + receive `{:rockbox, :track_changed, %Rockbox.Track{}}`. 14 + - **Plugins** — implement `Rockbox.Plugin` and install with 15 + `Rockbox.use_plugin/2`. 16 + 17 + --- 18 + 19 + ## Table of contents 20 + 21 + - [Installation](#installation) 22 + - [Quick start](#quick-start) 23 + - [Configuration](#configuration) 24 + - [API reference](#api-reference) 25 + - [Playback](#playback) 26 + - [Library](#library) 27 + - [Queue (live playlist)](#queue-live-playlist) 28 + - [Saved playlists](#saved-playlists) 29 + - [Smart playlists](#smart-playlists) 30 + - [Sound](#sound) 31 + - [Settings](#settings) 32 + - [System](#system) 33 + - [Browse (filesystem)](#browse-filesystem) 34 + - [Devices](#devices) 35 + - [Bluetooth](#bluetooth) 36 + - [Real-time events](#real-time-events) 37 + - [Plugins](#plugins) 38 + - [Error handling](#error-handling) 39 + - [Raw GraphQL queries](#raw-graphql-queries) 40 + 41 + --- 42 + 43 + ## Installation 44 + 45 + ```elixir 46 + def deps do 47 + [ 48 + {:rockbox_ex, "~> 0.1"} 49 + ] 50 + end 51 + ``` 52 + 53 + `rockboxd` must be running and reachable. By default the SDK connects to 54 + `http://localhost:6062/graphql`. Start rockboxd with: 55 + 56 + ```sh 57 + rockbox start 58 + ``` 59 + 60 + --- 61 + 62 + ## Quick start 63 + 64 + ```elixir 65 + client = Rockbox.new() 66 + 67 + # Optional: open the WebSocket so subscribers receive events 68 + {:ok, _pid} = Rockbox.connect(client) 69 + 70 + # What's playing right now? 71 + case Rockbox.Playback.current_track(client) do 72 + {:ok, %Rockbox.Track{} = t} -> IO.puts("▶ #{t.title} — #{t.artist}") 73 + {:ok, nil} -> IO.puts("Nothing is playing.") 74 + end 75 + 76 + # Search the library 77 + {:ok, results} = Rockbox.Library.search(client, "dark side") 78 + album = List.first(results.albums) 79 + 80 + # Play it shuffled 81 + :ok = Rockbox.Playback.play_album(client, album.id, shuffle: true) 82 + 83 + # React to track changes 84 + :ok = Rockbox.subscribe(:track_changed) 85 + 86 + receive do 87 + {:rockbox, :track_changed, track} -> 88 + IO.puts("Now: #{track.title}") 89 + end 90 + 91 + # Tear down when done 92 + Rockbox.disconnect(client) 93 + ``` 94 + 95 + --- 96 + 97 + ## Configuration 98 + 99 + ```elixir 100 + # Defaults: localhost:6062 101 + client = Rockbox.new() 102 + 103 + # Custom host and port 104 + client = Rockbox.new(host: "192.168.1.42", port: 6062) 105 + 106 + # Fully custom URLs (useful behind a reverse proxy) 107 + client = Rockbox.new( 108 + http_url: "https://music.home/graphql", 109 + ws_url: "wss://music.home/graphql" 110 + ) 111 + ``` 112 + 113 + | Option | Type | Default | Description | 114 + |-------------|------------------------|----------------------------------|-----------------------------------------------------| 115 + | `:host` | `String.t()` | `"localhost"` | Hostname or IP of rockboxd | 116 + | `:port` | `non_neg_integer()` | `6062` | GraphQL HTTP/WS port | 117 + | `:http_url` | `String.t()` | `http://{host}:{port}/graphql` | Override the full HTTP URL | 118 + | `:ws_url` | `String.t()` | `ws://{host}:{port}/graphql` | Override the full WebSocket URL | 119 + | `:headers` | `[{String.t(), String.t()}]` | `[]` | Extra HTTP request headers | 120 + | `:timeout` | `non_neg_integer()` | `15_000` | HTTP request timeout (ms) | 121 + 122 + --- 123 + 124 + ## API reference 125 + 126 + Every function comes in two flavors: 127 + 128 + - `name/N → {:ok, value} | {:error, exception}` — for `with`/`case` pipelines. 129 + - `name!/N → value` — raises `Rockbox.Error` (or a subclass) on failure. 130 + 131 + ### Playback 132 + 133 + ```elixir 134 + # Status — returns an atom: :stopped | :playing | :paused 135 + {:ok, :playing} = Rockbox.Playback.status(client) 136 + 137 + # Toggle 138 + case Rockbox.Playback.status!(client) do 139 + :playing -> Rockbox.Playback.pause(client) 140 + _ -> Rockbox.Playback.resume(client) 141 + end 142 + 143 + # Transport 144 + :ok = Rockbox.Playback.next(client) 145 + :ok = Rockbox.Playback.previous(client) 146 + :ok = Rockbox.Playback.stop(client) 147 + 148 + # Seek to absolute position (ms) 149 + :ok = Rockbox.Playback.seek(client, 90_000) 150 + 151 + # Current / next track — returns nil when stopped 152 + {:ok, %Rockbox.Track{title: t}} = Rockbox.Playback.current_track(client) 153 + {:ok, _next} = Rockbox.Playback.next_track(client) 154 + 155 + # Play helpers — single-call shortcuts 156 + :ok = Rockbox.Playback.play_track(client, "/Music/foo.mp3") 157 + :ok = Rockbox.Playback.play_album(client, "album-id", shuffle: true) 158 + :ok = Rockbox.Playback.play_artist(client, "artist-id", shuffle: true) 159 + :ok = Rockbox.Playback.play_playlist(client, "playlist-id") 160 + :ok = Rockbox.Playback.play_directory(client, "/Music/Jazz", recurse: true, shuffle: true) 161 + :ok = Rockbox.Playback.play_liked_tracks(client, shuffle: true) 162 + :ok = Rockbox.Playback.play_all_tracks(client, shuffle: true) 163 + ``` 164 + 165 + `Rockbox.Track` exposes a couple of helpers: 166 + 167 + ```elixir 168 + Rockbox.Track.format_length(track) # "4:32" 169 + Rockbox.Track.format_elapsed(track) # "1:14" 170 + Rockbox.Track.progress(track) # 0.27 (0.0–1.0) 171 + ``` 172 + 173 + ### Library 174 + 175 + ```elixir 176 + # Albums 177 + {:ok, albums} = Rockbox.Library.albums(client) 178 + {:ok, album} = Rockbox.Library.album(client, "album-id") # full track list 179 + {:ok, liked} = Rockbox.Library.liked_albums(client) 180 + :ok = Rockbox.Library.like_album(client, "album-id") 181 + :ok = Rockbox.Library.unlike_album(client, "album-id") 182 + 183 + # Artists 184 + {:ok, artists} = Rockbox.Library.artists(client) 185 + {:ok, artist} = Rockbox.Library.artist(client, "artist-id") 186 + 187 + # Tracks 188 + {:ok, tracks} = Rockbox.Library.tracks(client) 189 + {:ok, track} = Rockbox.Library.track(client, "track-id") 190 + {:ok, liked} = Rockbox.Library.liked_tracks(client) 191 + :ok = Rockbox.Library.like_track(client, "track-id") 192 + :ok = Rockbox.Library.unlike_track(client, "track-id") 193 + 194 + # Search across artists, albums, tracks, liked 195 + {:ok, results} = Rockbox.Library.search(client, "radiohead") 196 + results.artists # [%Rockbox.Artist{}, ...] 197 + results.albums # [%Rockbox.Album{}, ...] 198 + results.tracks # [%Rockbox.Track{}, ...] 199 + results.liked_tracks 200 + results.liked_albums 201 + 202 + # Trigger a full library rescan 203 + :ok = Rockbox.Library.scan(client) 204 + ``` 205 + 206 + ### Queue (live playlist) 207 + 208 + The *queue* is the live playback list — what plays right now. For persistent 209 + named collections see [Saved playlists](#saved-playlists). 210 + 211 + ```elixir 212 + {:ok, queue} = Rockbox.Queue.current(client) 213 + queue.amount # total tracks 214 + queue.index # 0-based position of the currently playing track 215 + queue.tracks # [%Rockbox.Track{}, ...] 216 + 217 + Rockbox.Playlist.current_track(queue) # convenience helper 218 + 219 + # Insertion: position is :next | :after_current | :last | :first 220 + :ok = Rockbox.Queue.insert_tracks(client, ["/Music/a.mp3", "/Music/b.mp3"], :next) 221 + :ok = Rockbox.Queue.insert_directory(client, "/Music/Ambient", :last) 222 + :ok = Rockbox.Queue.insert_album(client, "album-id", :next) 223 + 224 + # Other ops 225 + :ok = Rockbox.Queue.remove_track(client, 2) 226 + :ok = Rockbox.Queue.clear(client) 227 + :ok = Rockbox.Queue.shuffle(client) 228 + :ok = Rockbox.Queue.create(client, "Evening Mix", ["/a.mp3", "/b.mp3"]) 229 + :ok = Rockbox.Queue.resume(client) 230 + 231 + # Pipe-friendly chaining with bang variants 232 + client 233 + |> tap(&Rockbox.Queue.clear!/1) 234 + |> tap(&Rockbox.Queue.insert_tracks!(&1, ["/Music/a.mp3"], :last)) 235 + |> Rockbox.Queue.shuffle!() 236 + ``` 237 + 238 + ### Saved playlists 239 + 240 + ```elixir 241 + {:ok, lists} = Rockbox.SavedPlaylists.list(client) 242 + {:ok, lists} = Rockbox.SavedPlaylists.list(client, "folder-id") 243 + 244 + {:ok, pl} = Rockbox.SavedPlaylists.get(client, "playlist-id") 245 + {:ok, ids} = Rockbox.SavedPlaylists.track_ids(client, "playlist-id") 246 + 247 + # Create 248 + {:ok, pl} = 249 + Rockbox.SavedPlaylists.create(client, 250 + name: "Late Night Jazz", 251 + description: "Quiet music for working", 252 + folder_id: "folder-id", # optional 253 + track_ids: ["t1", "t2", "t3"] # optional 254 + ) 255 + 256 + # Update / add / remove 257 + :ok = Rockbox.SavedPlaylists.update(client, pl.id, name: "Late Night Jazz (v2)") 258 + :ok = Rockbox.SavedPlaylists.add_tracks(client, pl.id, ["t4", "t5"]) 259 + :ok = Rockbox.SavedPlaylists.remove_track(client, pl.id, "t1") 260 + 261 + # Play / delete 262 + :ok = Rockbox.SavedPlaylists.play(client, pl.id) 263 + :ok = Rockbox.SavedPlaylists.delete(client, pl.id) 264 + 265 + # Folders 266 + {:ok, folders} = Rockbox.SavedPlaylists.folders(client) 267 + {:ok, folder} = Rockbox.SavedPlaylists.create_folder(client, "Work") 268 + :ok = Rockbox.SavedPlaylists.delete_folder(client, folder.id) 269 + ``` 270 + 271 + ### Smart playlists 272 + 273 + Use the `Rockbox.SmartPlaylist.Rules` builder — pipe-friendly, type-safe. 274 + 275 + ```elixir 276 + alias Rockbox.SmartPlaylist.Rules 277 + 278 + rules = 279 + Rules.all_of() 280 + |> Rules.where(:play_count, :gte, 10) 281 + |> Rules.where(:last_played, :within, "30d") 282 + |> Rules.sort(:play_count, :desc) 283 + |> Rules.limit(50) 284 + |> Rules.to_json() 285 + 286 + {:ok, sp} = 287 + Rockbox.SmartPlaylists.create(client, 288 + name: "Most played (last 30d)", 289 + description: "Top 50 most-played tracks from the last month", 290 + rules: rules 291 + ) 292 + 293 + {:ok, ids} = Rockbox.SmartPlaylists.track_ids(client, sp.id) 294 + :ok = Rockbox.SmartPlaylists.play(client, sp.id) 295 + :ok = Rockbox.SmartPlaylists.delete(client, sp.id) 296 + 297 + # OR groups 298 + or_rules = 299 + Rules.any_of() 300 + |> Rules.where(:title, :contains, "Live") 301 + |> Rules.where(:title, :contains, "Acoustic") 302 + 303 + # Mixed AND/OR via where_group/2 304 + mixed = 305 + Rules.all_of() 306 + |> Rules.where(:play_count, :gt, 0) 307 + |> Rules.where_group(or_rules) 308 + |> Rules.to_json() 309 + ``` 310 + 311 + #### Listening stats 312 + 313 + ```elixir 314 + {:ok, stats} = Rockbox.SmartPlaylists.track_stats(client, "track-id") 315 + 316 + # Record events manually (e.g. from a scrobbler plugin) 317 + :ok = Rockbox.SmartPlaylists.record_played(client, "track-id") 318 + :ok = Rockbox.SmartPlaylists.record_skipped(client, "track-id") 319 + ``` 320 + 321 + ### Sound 322 + 323 + Volume is adjusted in firmware-defined steps. The number of steps per dB 324 + varies by hardware target — always inspect `volume/1` for the range. 325 + 326 + ```elixir 327 + {:ok, %Rockbox.Volume{volume: v, min: lo, max: hi}} = Rockbox.Sound.volume(client) 328 + 329 + {:ok, new_value} = Rockbox.Sound.adjust(client, 3) # +3 steps 330 + {:ok, _} = Rockbox.Sound.up(client) # +1 331 + {:ok, _} = Rockbox.Sound.down(client) # -1 332 + ``` 333 + 334 + ### Settings 335 + 336 + `update/2` accepts any subset of fields — only the ones you pass are written. 337 + 338 + ```elixir 339 + {:ok, settings} = Rockbox.Settings.get(client) 340 + 341 + # Toggle shuffle + repeat 342 + :ok = Rockbox.Settings.update(client, shuffle: true, repeat_mode: 1) 343 + 344 + # Equalizer 345 + :ok = 346 + Rockbox.Settings.update(client, 347 + eq_enabled: true, 348 + eq_precut: -3, 349 + eq_band_settings: [ 350 + %{cutoff: 60, q: 7, gain: 3}, 351 + %{cutoff: 200, q: 7, gain: 0}, 352 + %{cutoff: 4000, q: 7, gain: -2} 353 + ] 354 + ) 355 + 356 + # Compressor 357 + :ok = 358 + Rockbox.Settings.update(client, 359 + compressor_settings: %{ 360 + threshold: -24, makeup_gain: 3, ratio: 2, 361 + knee: 0, release_time: 100, attack_time: 5 362 + } 363 + ) 364 + 365 + # Replaygain 366 + :ok = 367 + Rockbox.Settings.update(client, 368 + replaygain_settings: %{noclip: true, type: 1, preamp: 0} 369 + ) 370 + ``` 371 + 372 + ### System 373 + 374 + ```elixir 375 + {:ok, version} = Rockbox.System.version(client) 376 + {:ok, status} = Rockbox.System.status(client) 377 + 378 + status.runtime # seconds since boot 379 + status.topruntime # peak runtime 380 + status.resume_index # last queued position 381 + ``` 382 + 383 + ### Browse (filesystem) 384 + 385 + ```elixir 386 + {:ok, entries} = Rockbox.Browse.entries(client) # music_dir root 387 + {:ok, entries} = Rockbox.Browse.entries(client, "/Music/Pink Floyd") 388 + 389 + for e <- entries do 390 + icon = if Rockbox.Entry.directory?(e), do: "📁", else: "🎵" 391 + IO.puts("#{icon} #{e.name}") 392 + end 393 + 394 + {:ok, dirs} = Rockbox.Browse.directories(client, "/Music") 395 + {:ok, files} = Rockbox.Browse.files(client, "/Music/Pink Floyd/The Wall") 396 + ``` 397 + 398 + ### Devices 399 + 400 + ```elixir 401 + {:ok, devices} = Rockbox.Devices.list(client) 402 + {:ok, device} = Rockbox.Devices.get(client, "device-id") 403 + 404 + # Connect — switches the active PCM output sink to this device 405 + :ok = Rockbox.Devices.connect(client, "chromecast-id") 406 + :ok = Rockbox.Devices.disconnect(client, "chromecast-id") 407 + ``` 408 + 409 + ### Bluetooth 410 + 411 + Linux only — backed by BlueZ. Calls return a `Rockbox.GraphQLError` on 412 + non-Linux hosts. 413 + 414 + ```elixir 415 + {:ok, devices} = Rockbox.Bluetooth.devices(client) 416 + {:ok, found} = Rockbox.Bluetooth.scan(client, 10) # 10 second scan 417 + :ok = Rockbox.Bluetooth.connect(client, "AA:BB:CC:DD:EE:FF") 418 + :ok = Rockbox.Bluetooth.disconnect(client, "AA:BB:CC:DD:EE:FF") 419 + ``` 420 + 421 + --- 422 + 423 + ## Real-time events 424 + 425 + Open the WebSocket once with `Rockbox.connect/1`. The connection is supervised 426 + and auto-reconnects with exponential backoff (capped at 30 s). Subscribers 427 + receive plain Erlang messages, so they integrate with `receive` blocks and 428 + `GenServer.handle_info/2`. 429 + 430 + ```elixir 431 + client = Rockbox.new() 432 + {:ok, _pid} = Rockbox.connect(client) 433 + 434 + :ok = Rockbox.subscribe(:track_changed) 435 + :ok = Rockbox.subscribe([:status_changed, :playlist_changed]) # multiple 436 + :ok = Rockbox.subscribe(:all) # catch-all 437 + 438 + receive do 439 + {:rockbox, :track_changed, %Rockbox.Track{} = track} -> 440 + IO.puts("▶ #{track.title} — #{track.artist}") 441 + 442 + {:rockbox, :status_changed, status} -> 443 + IO.puts("status → #{status}") # :stopped | :playing | :paused 444 + 445 + {:rockbox, :playlist_changed, %Rockbox.Playlist{} = queue} -> 446 + IO.puts("queue is now #{queue.amount} tracks") 447 + end 448 + 449 + Rockbox.unsubscribe(:track_changed) 450 + Rockbox.disconnect(client) 451 + ``` 452 + 453 + ### Event map 454 + 455 + | Event | Payload | 456 + |----------------------|-----------------------------------------------| 457 + | `:track_changed` | `%Rockbox.Track{}` | 458 + | `:status_changed` | `:stopped | :playing | :paused` | 459 + | `:playlist_changed` | `%Rockbox.Playlist{}` | 460 + | `:ws_open` | `nil` | 461 + | `:ws_close` | `nil` | 462 + | `:ws_error` | `Exception.t()` | 463 + 464 + Subscribers are auto-removed when their process exits — no manual cleanup 465 + needed. 466 + 467 + ### Inside a GenServer 468 + 469 + ```elixir 470 + defmodule MyApp.NowPlaying do 471 + use GenServer 472 + 473 + def start_link(client), do: GenServer.start_link(__MODULE__, client, name: __MODULE__) 474 + 475 + @impl true 476 + def init(client) do 477 + Rockbox.connect(client) 478 + Rockbox.subscribe([:track_changed, :status_changed]) 479 + {:ok, %{client: client, track: nil, status: :stopped}} 480 + end 481 + 482 + @impl true 483 + def handle_info({:rockbox, :track_changed, track}, state), 484 + do: {:noreply, %{state | track: track}} 485 + 486 + def handle_info({:rockbox, :status_changed, status}, state), 487 + do: {:noreply, %{state | status: status}} 488 + end 489 + ``` 490 + 491 + --- 492 + 493 + ## Plugins 494 + 495 + Plugins are the recommended way to bolt on cross-cutting features — scrobbling, 496 + desktop notifications, analytics, sleep timers — without forking the SDK. 497 + 498 + ### Writing a plugin 499 + 500 + ```elixir 501 + defmodule MyApp.LastFmScrobbler do 502 + @behaviour Rockbox.Plugin 503 + 504 + @impl true 505 + def name, do: "lastfm-scrobbler" 506 + @impl true 507 + def version, do: "1.0.0" 508 + @impl true 509 + def description, do: "Scrobble played tracks to Last.fm" 510 + 511 + @impl true 512 + def install(ctx) do 513 + {:ok, pid} = MyApp.LastFmScrobbler.Worker.start_link(ctx.client) 514 + {:ok, %{worker: pid}} 515 + end 516 + 517 + @impl true 518 + def uninstall(%{worker: pid}) do 519 + if Process.alive?(pid), do: GenServer.stop(pid) 520 + :ok 521 + end 522 + end 523 + 524 + defmodule MyApp.LastFmScrobbler.Worker do 525 + use GenServer 526 + 527 + def start_link(client), do: GenServer.start_link(__MODULE__, client) 528 + 529 + @impl true 530 + def init(client) do 531 + Rockbox.Events.subscribe(:track_changed) 532 + {:ok, %{client: client, current: nil, started_at: 0}} 533 + end 534 + 535 + @impl true 536 + def handle_info({:rockbox, :track_changed, track}, state) do 537 + now = System.monotonic_time(:millisecond) 538 + 539 + # Submit the previous track if it played for more than 30 s 540 + if state.current && now - state.started_at > 30_000 do 541 + submit_scrobble(state.current) 542 + end 543 + 544 + {:noreply, %{state | current: track, started_at: now}} 545 + end 546 + 547 + defp submit_scrobble(_track), do: :ok # talk to the Last.fm API here 548 + end 549 + ``` 550 + 551 + ### Installing 552 + 553 + ```elixir 554 + client = Rockbox.new() 555 + {:ok, _} = Rockbox.connect(client) 556 + 557 + :ok = Rockbox.use_plugin(client, MyApp.LastFmScrobbler) 558 + 559 + # Inspect what's installed 560 + for entry <- Rockbox.installed_plugins() do 561 + IO.puts("#{entry.module.name()} v#{entry.module.version()}") 562 + end 563 + 564 + :ok = Rockbox.unuse_plugin("lastfm-scrobbler") # by name 565 + :ok = Rockbox.unuse_plugin(MyApp.LastFmScrobbler) # or by module 566 + ``` 567 + 568 + The `install/1` callback receives `%{client: client}`. Return `{:ok, state}`; 569 + the state is passed back to `uninstall/1` so resources can be cleaned up. 570 + 571 + --- 572 + 573 + ## Error handling 574 + 575 + ```elixir 576 + case Rockbox.Playback.play(client) do 577 + :ok -> 578 + :ok 579 + 580 + {:error, %Rockbox.NetworkError{} = e} -> 581 + Logger.error("rockboxd unreachable: #{Exception.message(e)}") 582 + 583 + {:error, %Rockbox.GraphQLError{errors: errors}} -> 584 + for %{message: msg} <- errors, do: Logger.error("graphql: #{msg}") 585 + 586 + {:error, %Rockbox.Error{} = e} -> 587 + Logger.error("rockbox: #{Exception.message(e)}") 588 + end 589 + 590 + # …or use the bang variant inside a try/rescue 591 + try do 592 + Rockbox.Playback.play!(client) 593 + rescue 594 + e in Rockbox.NetworkError -> Logger.error("offline: #{e.message}") 595 + e in Rockbox.GraphQLError -> Logger.error("server: #{e.message}") 596 + end 597 + ``` 598 + 599 + | Exception | When raised | 600 + |---------------------------|----------------------------------------------------------| 601 + | `Rockbox.NetworkError` | HTTP request fails or returns a non-2xx status | 602 + | `Rockbox.GraphQLError` | Server returns `{ "errors": [...] }` in the response body | 603 + | `Rockbox.Error` | Base exception — rescue this to catch any SDK failure | 604 + 605 + --- 606 + 607 + ## Raw GraphQL queries 608 + 609 + For operations not yet covered by a dedicated function, drop down to 610 + `Rockbox.query/3`. Variables can be a map or keyword list — snake_case keys 611 + are converted to camelCase before being sent. 612 + 613 + ```elixir 614 + {:ok, %{"rockboxVersion" => v}} = 615 + Rockbox.query(client, "query { rockboxVersion }") 616 + 617 + {:ok, %{"album" => album}} = 618 + Rockbox.query( 619 + client, 620 + "query Album($id: String!) { album(id: $id) { id title artist year } }", 621 + id: "abc-123" 622 + ) 623 + 624 + # Mutation 625 + :ok = Rockbox.query(client, "mutation Seek($t: Int!) { fastForwardRewind(newTime: $t) }", t: 120_000) |> elem(0) == :ok 626 + ``` 627 + 628 + The GraphiQL explorer is available at `http://localhost:6062/graphiql` while 629 + rockboxd is running. 630 + 631 + --- 632 + 633 + ## Module map 634 + 635 + | Domain | Module | 636 + |-----------------------|---------------------------------| 637 + | Client constructor | `Rockbox`, `Rockbox.Client` | 638 + | Transport controls | `Rockbox.Playback` | 639 + | Library / search | `Rockbox.Library` | 640 + | Live queue | `Rockbox.Queue` | 641 + | Saved playlists | `Rockbox.SavedPlaylists` | 642 + | Smart playlists | `Rockbox.SmartPlaylists` | 643 + | Smart-playlist rules | `Rockbox.SmartPlaylist.Rules` | 644 + | Volume | `Rockbox.Sound` | 645 + | Settings | `Rockbox.Settings` | 646 + | System info | `Rockbox.System` | 647 + | Filesystem browser | `Rockbox.Browse` | 648 + | Output devices | `Rockbox.Devices` | 649 + | Bluetooth | `Rockbox.Bluetooth` | 650 + | Real-time events | `Rockbox.Events` | 651 + | Plugin behaviour | `Rockbox.Plugin`, `Rockbox.Plugins` | 652 + | Errors | `Rockbox.Error`, `Rockbox.NetworkError`, `Rockbox.GraphQLError` | 653 + 654 + --- 655 + 656 + ## Development 657 + 658 + ```sh 659 + mix deps.get 660 + mix test 661 + mix docs # generates HTML docs in doc/ 662 + ``` 663 + 664 + Examples live in `examples/`. Start `rockboxd`, then: 665 + 666 + ```sh 667 + mix run examples/01_basic_playback.exs 668 + mix run --no-halt examples/02_now_playing.exs 669 + ``` 670 + 671 + --- 672 + 673 + ## License 674 + 675 + MIT License. See [LICENSE](./LICENSE) for details.
+47
sdk/elixir/examples/01_basic_playback.exs
··· 1 + # 01 — Basic playback 2 + # 3 + # Inspect the current track, then either pause or resume based on the current 4 + # state. Idempotent: run it twice and it toggles between Playing and Paused. 5 + # 6 + # mix run examples/01_basic_playback.exs 7 + 8 + Code.require_file("_helper.exs", __DIR__) 9 + 10 + client = Examples.Helper.client() 11 + 12 + {:ok, status} = Rockbox.Playback.status(client) 13 + IO.puts("Status: #{status}") 14 + 15 + case Rockbox.Playback.current_track(client) do 16 + {:ok, %Rockbox.Track{} = track} -> 17 + pct = 18 + if track.length > 0, 19 + do: round(track.elapsed / track.length * 100), 20 + else: 0 21 + 22 + IO.puts("Now: #{track.title} — #{track.artist}") 23 + 24 + IO.puts( 25 + " #{Rockbox.Track.format_elapsed(track)} / #{Rockbox.Track.format_length(track)} (#{pct}%)" 26 + ) 27 + 28 + {:ok, nil} -> 29 + IO.puts("Nothing is playing.") 30 + 31 + {:error, e} -> 32 + IO.puts(:stderr, "Error: #{Exception.message(e)}") 33 + System.halt(1) 34 + end 35 + 36 + case status do 37 + :playing -> 38 + :ok = Rockbox.Playback.pause(client) 39 + IO.puts("→ paused") 40 + 41 + :paused -> 42 + :ok = Rockbox.Playback.resume(client) 43 + IO.puts("→ resumed") 44 + 45 + _ -> 46 + :ok 47 + end
+29
sdk/elixir/examples/02_now_playing.exs
··· 1 + # 02 — Real-time "Now Playing" stream 2 + # 3 + # Open the WebSocket and print track changes as they happen. 4 + # 5 + # mix run --no-halt examples/02_now_playing.exs 6 + 7 + Code.require_file("_helper.exs", __DIR__) 8 + 9 + client = Examples.Helper.client() 10 + {:ok, _pid} = Rockbox.connect(client) 11 + :ok = Rockbox.subscribe([:track_changed, :status_changed]) 12 + 13 + IO.puts("Listening for events. Press Ctrl+C to exit.") 14 + 15 + defmodule Loop do 16 + def run do 17 + receive do 18 + {:rockbox, :track_changed, %Rockbox.Track{} = t} -> 19 + IO.puts("▶ #{t.title} — #{t.artist} (#{Rockbox.format_ms(t.length)})") 20 + run() 21 + 22 + {:rockbox, :status_changed, status} -> 23 + IO.puts("· status → #{status}") 24 + run() 25 + end 26 + end 27 + end 28 + 29 + Loop.run()
+24
sdk/elixir/examples/03_library_search.exs
··· 1 + # 03 — Library search 2 + # 3 + # mix run examples/03_library_search.exs "dark side" 4 + 5 + Code.require_file("_helper.exs", __DIR__) 6 + 7 + term = List.first(System.argv()) || "love" 8 + client = Examples.Helper.client() 9 + 10 + {:ok, results} = Rockbox.Library.search(client, term) 11 + 12 + IO.puts("Search: \"#{term}\"") 13 + IO.puts(" Artists (#{length(results.artists)}):") 14 + for a <- Enum.take(results.artists, 5), do: IO.puts(" • #{a.name}") 15 + 16 + IO.puts(" Albums (#{length(results.albums)}):") 17 + 18 + for a <- Enum.take(results.albums, 5), 19 + do: IO.puts(" • #{a.title} — #{a.artist} (#{a.year})") 20 + 21 + IO.puts(" Tracks (#{length(results.tracks)}):") 22 + 23 + for t <- Enum.take(results.tracks, 5), 24 + do: IO.puts(" • #{t.title} — #{t.artist}")
+27
sdk/elixir/examples/04_queue_management.exs
··· 1 + # 04 — Queue management 2 + # 3 + # Inspect the live playback queue and demonstrate insert / remove / clear. 4 + # 5 + # mix run examples/04_queue_management.exs 6 + 7 + Code.require_file("_helper.exs", __DIR__) 8 + 9 + client = Examples.Helper.client() 10 + 11 + {:ok, queue} = Rockbox.Queue.current(client) 12 + IO.puts("Queue: #{queue.amount} tracks, currently at index #{queue.index}") 13 + 14 + queue.tracks 15 + |> Enum.with_index() 16 + |> Enum.take(10) 17 + |> Enum.each(fn {t, i} -> 18 + marker = if i == queue.index, do: "▶", else: " " 19 + IO.puts("#{marker} #{i + 1}. #{t.title} — #{t.artist}") 20 + end) 21 + 22 + # Pipe-friendly chained ops (uses bang variants): 23 + # 24 + # client 25 + # |> tap(&Rockbox.Queue.clear!/1) 26 + # |> tap(&Rockbox.Queue.insert_tracks!(&1, ["/Music/a.mp3", "/Music/b.mp3"], :last)) 27 + # |> Rockbox.Queue.shuffle!()
+20
sdk/elixir/examples/05_saved_playlists.exs
··· 1 + # 05 — Saved playlists 2 + # 3 + # mix run examples/05_saved_playlists.exs 4 + 5 + Code.require_file("_helper.exs", __DIR__) 6 + 7 + client = Examples.Helper.client() 8 + 9 + {:ok, lists} = Rockbox.SavedPlaylists.list(client) 10 + IO.puts("You have #{length(lists)} saved playlist(s):") 11 + 12 + for pl <- lists do 13 + IO.puts(" • #{pl.name} — #{pl.track_count} tracks (id: #{pl.id})") 14 + end 15 + 16 + # Create + add tracks + delete (uncomment to demo): 17 + # 18 + # {:ok, pl} = Rockbox.SavedPlaylists.create(client, name: "Demo", description: "test") 19 + # :ok = Rockbox.SavedPlaylists.add_tracks(client, pl.id, ["track-id-1", "track-id-2"]) 20 + # :ok = Rockbox.SavedPlaylists.delete(client, pl.id)
+45
sdk/elixir/examples/06_smart_playlist.exs
··· 1 + # 06 — Smart playlist with the Rules builder 2 + # 3 + # mix run examples/06_smart_playlist.exs 4 + 5 + Code.require_file("_helper.exs", __DIR__) 6 + 7 + alias Rockbox.SmartPlaylist.Rules 8 + 9 + client = Examples.Helper.client() 10 + 11 + rules = 12 + Rules.all_of() 13 + |> Rules.where(:play_count, :gte, 1) 14 + |> Rules.sort(:play_count, :desc) 15 + |> Rules.limit(25) 16 + |> Rules.to_json() 17 + 18 + {:ok, sp} = 19 + Rockbox.SmartPlaylists.create(client, 20 + name: "Most played (demo)", 21 + description: "Top 25 most-played tracks", 22 + rules: rules 23 + ) 24 + 25 + IO.puts("Created smart playlist: #{sp.id}") 26 + 27 + {:ok, ids} = Rockbox.SmartPlaylists.track_ids(client, sp.id) 28 + IO.puts("Currently resolves to #{length(ids)} tracks") 29 + 30 + case List.first(ids) do 31 + nil -> 32 + :ok 33 + 34 + top_id -> 35 + {:ok, stats} = Rockbox.SmartPlaylists.track_stats(client, top_id) 36 + 37 + if stats do 38 + IO.puts( 39 + "Top track stats: played #{stats.play_count}× (skipped #{stats.skip_count}×)" 40 + ) 41 + end 42 + end 43 + 44 + :ok = Rockbox.SmartPlaylists.delete(client, sp.id) 45 + IO.puts("Cleaned up demo smart playlist.")
+16
sdk/elixir/examples/07_volume_control.exs
··· 1 + # 07 — Volume control 2 + # 3 + # mix run examples/07_volume_control.exs 4 + 5 + Code.require_file("_helper.exs", __DIR__) 6 + 7 + client = Examples.Helper.client() 8 + 9 + {:ok, vol} = Rockbox.Sound.volume(client) 10 + IO.puts("Current: #{vol.volume} (range #{vol.min}..#{vol.max})") 11 + 12 + {:ok, after_up} = Rockbox.Sound.up(client) 13 + IO.puts("After +1: #{after_up}") 14 + 15 + {:ok, _} = Rockbox.Sound.down(client) 16 + IO.puts("Reverted.")
+31
sdk/elixir/examples/08_eq_config.exs
··· 1 + # 08 — EQ configuration 2 + # 3 + # Enable the equalizer with a 5-band bass-boost / presence-cut profile. 4 + # 5 + # mix run examples/08_eq_config.exs 6 + 7 + Code.require_file("_helper.exs", __DIR__) 8 + 9 + client = Examples.Helper.client() 10 + 11 + :ok = 12 + Rockbox.Settings.update(client, 13 + eq_enabled: true, 14 + eq_precut: -3, 15 + eq_band_settings: [ 16 + %{cutoff: 60, q: 7, gain: 3}, 17 + %{cutoff: 200, q: 7, gain: 0}, 18 + %{cutoff: 800, q: 7, gain: 0}, 19 + %{cutoff: 4000, q: 7, gain: -2}, 20 + %{cutoff: 12_000, q: 7, gain: 1} 21 + ] 22 + ) 23 + 24 + {:ok, settings} = Rockbox.Settings.get(client) 25 + 26 + IO.puts("EQ enabled: #{settings.eq_enabled}") 27 + IO.puts("EQ precut: #{settings.eq_precut}") 28 + 29 + for band <- settings.eq_band_settings do 30 + IO.puts(" #{String.pad_leading(Integer.to_string(band.cutoff), 6)} Hz q=#{band.q} #{band.gain} dB") 31 + end
+16
sdk/elixir/examples/09_browse_filesystem.exs
··· 1 + # 09 — Browse the filesystem 2 + # 3 + # mix run examples/09_browse_filesystem.exs # music_dir root 4 + # mix run examples/09_browse_filesystem.exs /Music/Pink\ Floyd 5 + 6 + Code.require_file("_helper.exs", __DIR__) 7 + 8 + client = Examples.Helper.client() 9 + path = List.first(System.argv()) 10 + 11 + {:ok, entries} = Rockbox.Browse.entries(client, path) 12 + 13 + for e <- entries do 14 + icon = if Rockbox.Entry.directory?(e), do: "[dir] ", else: " " 15 + IO.puts("#{icon}#{e.name}") 16 + end
+23
sdk/elixir/examples/10_devices.exs
··· 1 + # 10 — List remote output devices (Chromecast / AirPlay / …) 2 + # 3 + # mix run examples/10_devices.exs 4 + 5 + Code.require_file("_helper.exs", __DIR__) 6 + 7 + client = Examples.Helper.client() 8 + {:ok, devices} = Rockbox.Devices.list(client) 9 + 10 + IO.puts("Discovered #{length(devices)} device(s):") 11 + 12 + for d <- devices do 13 + status = if d.is_connected, do: "● connected", else: "○ available" 14 + 15 + type = 16 + cond do 17 + d.is_cast_device -> "Cast" 18 + d.is_source_device -> "Source" 19 + true -> "Other" 20 + end 21 + 22 + IO.puts(" [#{type}] #{d.name} (#{d.ip}:#{d.port}) — #{status}") 23 + end
+45
sdk/elixir/examples/11_bluetooth.exs
··· 1 + # 11 — Bluetooth (Linux only) 2 + # 3 + # mix run examples/11_bluetooth.exs # list paired devices 4 + # mix run examples/11_bluetooth.exs scan # scan 10s for devices 5 + # mix run examples/11_bluetooth.exs connect AA:BB:.. # connect by address 6 + # mix run examples/11_bluetooth.exs disconnect AA:BB:.. 7 + 8 + Code.require_file("_helper.exs", __DIR__) 9 + 10 + client = Examples.Helper.client() 11 + 12 + format = fn d -> 13 + flags = 14 + [ 15 + if(d.connected, do: "connected"), 16 + if(d.paired, do: "paired"), 17 + if(d.trusted, do: "trusted") 18 + ] 19 + |> Enum.reject(&is_nil/1) 20 + |> Enum.join(", ") 21 + 22 + rssi = if d.rssi, do: " #{d.rssi} dBm", else: "" 23 + " #{d.address} #{String.pad_trailing(d.name || "", 28)}#{rssi} [#{flags}]" 24 + end 25 + 26 + case System.argv() do 27 + ["scan"] -> 28 + IO.puts("Scanning for 10s...") 29 + {:ok, found} = Rockbox.Bluetooth.scan(client, 10) 30 + IO.puts("Found #{length(found)} devices:") 31 + for d <- found, do: IO.puts(format.(d)) 32 + 33 + ["connect", addr] -> 34 + :ok = Rockbox.Bluetooth.connect(client, addr) 35 + IO.puts("Connected to #{addr}") 36 + 37 + ["disconnect", addr] -> 38 + :ok = Rockbox.Bluetooth.disconnect(client, addr) 39 + IO.puts("Disconnected from #{addr}") 40 + 41 + _ -> 42 + {:ok, devices} = Rockbox.Bluetooth.devices(client) 43 + IO.puts("Paired devices (#{length(devices)}):") 44 + for d <- devices, do: IO.puts(format.(d)) 45 + end
+74
sdk/elixir/examples/12_plugin_sleep_timer.exs
··· 1 + # 12 — Plugin: sleep timer 2 + # 3 + # Demonstrates the Rockbox.Plugin behaviour. Stops playback after N minutes; 4 + # cancels itself if the user already stopped playback manually. 5 + # 6 + # mix run --no-halt examples/12_plugin_sleep_timer.exs # default 30 min 7 + # mix run --no-halt examples/12_plugin_sleep_timer.exs 5 # 5 min 8 + 9 + Code.require_file("_helper.exs", __DIR__) 10 + 11 + defmodule SleepTimer do 12 + @behaviour Rockbox.Plugin 13 + use GenServer 14 + require Logger 15 + 16 + @impl Rockbox.Plugin 17 + def name, do: "sleep-timer" 18 + @impl Rockbox.Plugin 19 + def version, do: "1.0.0" 20 + @impl Rockbox.Plugin 21 + def description, do: "Stop playback after N minutes" 22 + 23 + @impl Rockbox.Plugin 24 + def install(ctx) do 25 + minutes = Application.get_env(:examples, :sleep_minutes, 30) 26 + {:ok, pid} = GenServer.start_link(__MODULE__, {ctx.client, minutes}) 27 + {:ok, %{pid: pid}} 28 + end 29 + 30 + @impl Rockbox.Plugin 31 + def uninstall(%{pid: pid}) do 32 + if Process.alive?(pid), do: GenServer.stop(pid, :normal) 33 + :ok 34 + end 35 + 36 + # GenServer callbacks 37 + @impl GenServer 38 + def init({client, minutes}) do 39 + Rockbox.Events.subscribe(:status_changed) 40 + Process.send_after(self(), :fire, minutes * 60_000) 41 + fire_at = DateTime.add(DateTime.utc_now(), minutes * 60, :second) 42 + IO.puts("💤 Sleep timer armed — will stop playback at #{fire_at}") 43 + {:ok, %{client: client}} 44 + end 45 + 46 + @impl GenServer 47 + def handle_info(:fire, %{client: client} = state) do 48 + IO.puts("💤 Time's up — stopping playback.") 49 + Rockbox.Playback.stop(client) 50 + {:stop, :normal, state} 51 + end 52 + 53 + def handle_info({:rockbox, :status_changed, :stopped}, state) do 54 + IO.puts("💤 Playback stopped manually — sleep timer cancelled.") 55 + {:stop, :normal, state} 56 + end 57 + 58 + def handle_info(_other, state), do: {:noreply, state} 59 + end 60 + 61 + minutes = 62 + case System.argv() do 63 + [m | _] -> String.to_integer(m) 64 + _ -> 30 65 + end 66 + 67 + Application.put_env(:examples, :sleep_minutes, minutes) 68 + 69 + client = Examples.Helper.client() 70 + {:ok, _} = Rockbox.connect(client) 71 + :ok = Rockbox.use_plugin(client, SleepTimer) 72 + 73 + IO.puts("Plugin installed. Press Ctrl+C twice to exit.") 74 + Process.sleep(:infinity)
+24
sdk/elixir/examples/13_raw_query.exs
··· 1 + # 13 — Raw GraphQL escape hatch 2 + # 3 + # For operations not yet covered by a dedicated SDK function, drop down to 4 + # `Rockbox.query/3` (snake_case variables are converted to camelCase). 5 + # 6 + # mix run examples/13_raw_query.exs 7 + 8 + Code.require_file("_helper.exs", __DIR__) 9 + 10 + client = Examples.Helper.client() 11 + 12 + {:ok, %{"rockboxVersion" => v}} = Rockbox.query(client, "query { rockboxVersion }") 13 + IO.puts("rockboxd #{v}") 14 + 15 + {:ok, %{"album" => album}} = 16 + Rockbox.query( 17 + client, 18 + """ 19 + query Album($id: String!) { album(id: $id) { id title artist year } } 20 + """, 21 + id: "demo-id-or-use-a-real-one" 22 + ) 23 + 24 + IO.inspect(album, label: "album")
+28
sdk/elixir/examples/README.md
··· 1 + # Examples 2 + 3 + Each example is a runnable script. Start `rockboxd` first, then: 4 + 5 + ```sh 6 + mix deps.get 7 + mix run examples/01_basic_playback.exs 8 + ``` 9 + 10 + Override the host/port via env: `ROCKBOX_HOST`, `ROCKBOX_PORT`. 11 + 12 + | File | What it shows | 13 + |-----------------------------------|------------------------------------------------| 14 + | `01_basic_playback.exs` | Toggle play/pause based on current status | 15 + | `02_now_playing.exs` | Real-time event subscriptions | 16 + | `03_library_search.exs` | Full-text search across artists/albums/tracks | 17 + | `04_queue_management.exs` | Inspect and modify the live queue | 18 + | `05_saved_playlists.exs` | Persistent named playlists | 19 + | `06_smart_playlist.exs` | Builder DSL for rule-based playlists | 20 + | `07_volume_control.exs` | Volume up/down | 21 + | `08_eq_config.exs` | Equalizer configuration | 22 + | `09_browse_filesystem.exs` | Walk `music_dir` | 23 + | `10_devices.exs` | List Chromecast / AirPlay devices | 24 + | `11_bluetooth.exs` | Bluetooth scan / connect (Linux) | 25 + | `12_plugin_sleep_timer.exs` | Build a plugin with the `Rockbox.Plugin` behaviour | 26 + | `13_raw_query.exs` | Escape hatch for one-off GraphQL queries | 27 + 28 + For long-running examples (subscriptions, plugins) use `mix run --no-halt …`.
+15
sdk/elixir/examples/_helper.exs
··· 1 + # Shared client factory used by every example. 2 + # 3 + # Override the host/port via env: ROCKBOX_HOST, ROCKBOX_PORT. 4 + 5 + defmodule Examples.Helper do 6 + def client do 7 + Rockbox.new( 8 + host: System.get_env("ROCKBOX_HOST", "localhost"), 9 + port: String.to_integer(System.get_env("ROCKBOX_PORT", "6062")) 10 + ) 11 + end 12 + 13 + @doc "Format milliseconds as M:SS." 14 + def fmt_time(ms), do: Rockbox.format_ms(ms) 15 + end
+161
sdk/elixir/lib/rockbox.ex
··· 1 + defmodule Rockbox do 2 + @moduledoc """ 3 + Idiomatic Elixir SDK for [Rockbox](https://www.rockbox.org). 4 + 5 + ## Quick start 6 + 7 + client = Rockbox.new() 8 + 9 + # Optional: open the WebSocket for real-time events 10 + {:ok, _} = Rockbox.connect(client) 11 + :ok = Rockbox.Events.subscribe(:track_changed) 12 + 13 + # Look at what's playing 14 + {:ok, track} = Rockbox.Playback.current_track(client) 15 + IO.inspect(track) 16 + 17 + # Search the library 18 + {:ok, results} = Rockbox.Library.search(client, "radiohead") 19 + 20 + # Play an album, shuffled 21 + :ok = Rockbox.Playback.play_album(client, hd(results.albums).id, shuffle: true) 22 + 23 + # React to track changes 24 + receive do 25 + {:rockbox, :track_changed, track} -> 26 + IO.puts("▶ \#{track.title} — \#{track.artist}") 27 + end 28 + 29 + ## Module map 30 + 31 + | Domain | Module | 32 + |-----------------------|----------------------------| 33 + | Transport controls | `Rockbox.Playback` | 34 + | Library / search | `Rockbox.Library` | 35 + | Live queue | `Rockbox.Queue` | 36 + | Saved playlists | `Rockbox.SavedPlaylists` | 37 + | Smart playlists | `Rockbox.SmartPlaylists` | 38 + | Smart-playlist rules | `Rockbox.SmartPlaylist.Rules` | 39 + | Volume | `Rockbox.Sound` | 40 + | Settings | `Rockbox.Settings` | 41 + | System info | `Rockbox.System` | 42 + | Filesystem browser | `Rockbox.Browse` | 43 + | Output devices | `Rockbox.Devices` | 44 + | Bluetooth (Linux) | `Rockbox.Bluetooth` | 45 + | Real-time events | `Rockbox.Events` | 46 + | Plugin behaviour | `Rockbox.Plugin` | 47 + 48 + Every API function takes the client as its first argument so it composes 49 + with the pipe operator. Functions that may fail return 50 + `{:ok, value} | {:error, exception}` and have a matching `name!/N` variant 51 + that raises on error. 52 + """ 53 + 54 + alias Rockbox.{Client, Plugins, Socket, Transport} 55 + 56 + # --------------------------------------------------------------------------- 57 + # Client construction 58 + # --------------------------------------------------------------------------- 59 + 60 + @doc """ 61 + Build a new client. See `Rockbox.Client` for the full option list. 62 + 63 + iex> client = Rockbox.new() 64 + iex> client.host 65 + "localhost" 66 + """ 67 + @spec new(Client.opts()) :: Client.t() 68 + def new(opts \\ []), do: Client.new(opts) 69 + 70 + # --------------------------------------------------------------------------- 71 + # Real-time subscriptions 72 + # --------------------------------------------------------------------------- 73 + 74 + @doc """ 75 + Open the WebSocket connection so subscribers start receiving events. 76 + 77 + Idempotent — calling it twice for the same client returns the existing pid. 78 + Subscribe with `Rockbox.Events.subscribe/1` (or `Rockbox.subscribe/1`). 79 + """ 80 + @spec connect(Client.t()) :: {:ok, pid()} | {:error, term()} 81 + def connect(%Client{} = client) do 82 + case Socket.whereis(client) do 83 + nil -> 84 + DynamicSupervisor.start_child(Rockbox.SocketSupervisor, {Socket, client}) 85 + 86 + pid -> 87 + {:ok, pid} 88 + end 89 + end 90 + 91 + @doc "Tear down the WebSocket connection for `client`." 92 + @spec disconnect(Client.t()) :: :ok 93 + def disconnect(%Client{} = client), do: Socket.stop(client) 94 + 95 + @doc "Shortcut for `Rockbox.Events.subscribe/1`." 96 + defdelegate subscribe(event), to: Rockbox.Events 97 + 98 + @doc "Shortcut for `Rockbox.Events.unsubscribe/1`." 99 + defdelegate unsubscribe(event), to: Rockbox.Events 100 + 101 + # --------------------------------------------------------------------------- 102 + # Plugins 103 + # --------------------------------------------------------------------------- 104 + 105 + @doc "Install a plugin module. See `Rockbox.Plugin` for the behaviour." 106 + @spec use_plugin(Client.t(), module()) :: :ok | {:error, term()} 107 + def use_plugin(%Client{} = client, plugin), do: Plugins.install(client, plugin) 108 + 109 + @doc "Uninstall a plugin by module or by name string." 110 + @spec unuse_plugin(module() | String.t()) :: :ok 111 + def unuse_plugin(plugin), do: Plugins.uninstall(plugin) 112 + 113 + @doc "List currently installed plugins." 114 + @spec installed_plugins() :: [Plugins.entry()] 115 + def installed_plugins, do: Plugins.list() 116 + 117 + # --------------------------------------------------------------------------- 118 + # Raw GraphQL escape hatch 119 + # --------------------------------------------------------------------------- 120 + 121 + @doc """ 122 + Run a raw GraphQL query or mutation. Variables can be a map or keyword list 123 + (snake_case keys are converted to camelCase). Returns `{:ok, data}`. 124 + 125 + Rockbox.query(client, 126 + \"\"\" 127 + query Album($id: String!) { album(id: $id) { id title artist } } 128 + \"\"\", 129 + id: "abc-123" 130 + ) 131 + """ 132 + @spec query(Client.t(), String.t(), map() | keyword() | nil) :: 133 + {:ok, map()} | {:error, Exception.t()} 134 + def query(%Client{} = client, gql, variables \\ nil), 135 + do: Transport.execute(client, gql, variables) 136 + 137 + @doc "Same as `query/3` but raises on error." 138 + @spec query!(Client.t(), String.t(), map() | keyword() | nil) :: map() 139 + def query!(%Client{} = client, gql, variables \\ nil), 140 + do: Transport.execute!(client, gql, variables) 141 + 142 + # --------------------------------------------------------------------------- 143 + # Misc helpers 144 + # --------------------------------------------------------------------------- 145 + 146 + @doc """ 147 + Format a millisecond duration as `M:SS`. 148 + 149 + iex> Rockbox.format_ms(75_000) 150 + "1:15" 151 + """ 152 + @spec format_ms(integer()) :: String.t() 153 + def format_ms(ms) when is_integer(ms) and ms >= 0 do 154 + total = div(ms, 1000) 155 + minutes = div(total, 60) 156 + seconds = rem(total, 60) 157 + "#{minutes}:#{seconds |> Integer.to_string() |> String.pad_leading(2, "0")}" 158 + end 159 + 160 + def format_ms(_), do: "0:00" 161 + end
+29
sdk/elixir/lib/rockbox/album.ex
··· 1 + defmodule Rockbox.Album do 2 + @moduledoc "An album in the music library. `:tracks` may be partially populated." 3 + 4 + @type t :: %__MODULE__{ 5 + id: String.t(), 6 + title: String.t(), 7 + artist: String.t(), 8 + year: integer(), 9 + year_string: String.t(), 10 + album_art: String.t() | nil, 11 + md5: String.t(), 12 + artist_id: String.t(), 13 + copyright_message: String.t() | nil, 14 + tracks: [Rockbox.Track.t()] 15 + } 16 + 17 + defstruct [ 18 + :id, 19 + :title, 20 + :artist, 21 + :year, 22 + :year_string, 23 + :album_art, 24 + :md5, 25 + :artist_id, 26 + :copyright_message, 27 + tracks: [] 28 + ] 29 + end
+17
sdk/elixir/lib/rockbox/application.ex
··· 1 + defmodule Rockbox.Application do 2 + @moduledoc false 3 + 4 + use Application 5 + 6 + @impl true 7 + def start(_type, _args) do 8 + children = [ 9 + {Registry, keys: :duplicate, name: Rockbox.Subscribers}, 10 + {Registry, keys: :unique, name: Rockbox.Sockets}, 11 + {DynamicSupervisor, strategy: :one_for_one, name: Rockbox.SocketSupervisor}, 12 + Rockbox.Plugins 13 + ] 14 + 15 + Supervisor.start_link(children, strategy: :one_for_one, name: Rockbox.Supervisor) 16 + end 17 + end
+14
sdk/elixir/lib/rockbox/artist.ex
··· 1 + defmodule Rockbox.Artist do 2 + @moduledoc "An artist with their albums and tracks." 3 + 4 + @type t :: %__MODULE__{ 5 + id: String.t(), 6 + name: String.t(), 7 + bio: String.t() | nil, 8 + image: String.t() | nil, 9 + tracks: [Rockbox.Track.t()], 10 + albums: [Rockbox.Album.t()] 11 + } 12 + 13 + defstruct [:id, :name, :bio, :image, tracks: [], albums: []] 14 + end
+71
sdk/elixir/lib/rockbox/bluetooth.ex
··· 1 + defmodule Rockbox.Bluetooth do 2 + @moduledoc """ 3 + Bluetooth device management (Linux only — backed by BlueZ). 4 + 5 + Calls return `{:error, %Rockbox.GraphQLError{}}` on non-Linux hosts with the 6 + message `"Bluetooth is only supported on Linux"`. 7 + """ 8 + 9 + alias Rockbox.{BluetoothDevice, Client, Transport, Util} 10 + 11 + @fragment ~S""" 12 + fragment BluetoothDeviceFields on BluetoothDevice { 13 + address name paired trusted connected rssi 14 + } 15 + """ 16 + 17 + @spec devices(Client.t()) :: {:ok, [BluetoothDevice.t()]} | {:error, Exception.t()} 18 + def devices(client) do 19 + query = 20 + @fragment <> "query BluetoothDevices { bluetoothDevices { ...BluetoothDeviceFields } }" 21 + 22 + with {:ok, %{"bluetoothDevices" => list}} <- Transport.execute(client, query) do 23 + {:ok, Util.to_struct_list(BluetoothDevice, list)} 24 + end 25 + end 26 + 27 + @spec devices!(Client.t()) :: [BluetoothDevice.t()] 28 + def devices!(client), do: bang(devices(client)) 29 + 30 + @doc "Scan for nearby devices. `timeout_secs` defaults to the firmware default." 31 + @spec scan(Client.t(), pos_integer() | nil) :: 32 + {:ok, [BluetoothDevice.t()]} | {:error, Exception.t()} 33 + def scan(client, timeout_secs \\ nil) do 34 + query = 35 + @fragment <> 36 + "mutation BluetoothScan($timeoutSecs: Int) { bluetoothScan(timeoutSecs: $timeoutSecs) { ...BluetoothDeviceFields } }" 37 + 38 + with {:ok, %{"bluetoothScan" => list}} <- 39 + Transport.execute(client, query, %{timeout_secs: timeout_secs}) do 40 + {:ok, Util.to_struct_list(BluetoothDevice, list)} 41 + end 42 + end 43 + 44 + @spec connect(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 45 + def connect(client, address), 46 + do: 47 + void( 48 + Transport.execute( 49 + client, 50 + "mutation BluetoothConnect($address: String!) { bluetoothConnect(address: $address) }", 51 + %{address: address} 52 + ) 53 + ) 54 + 55 + @spec disconnect(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 56 + def disconnect(client, address), 57 + do: 58 + void( 59 + Transport.execute( 60 + client, 61 + "mutation BluetoothDisconnect($address: String!) { bluetoothDisconnect(address: $address) }", 62 + %{address: address} 63 + ) 64 + ) 65 + 66 + defp void({:ok, _}), do: :ok 67 + defp void(err), do: err 68 + 69 + defp bang({:ok, value}), do: value 70 + defp bang({:error, exception}), do: raise(exception) 71 + end
+14
sdk/elixir/lib/rockbox/bluetooth_device.ex
··· 1 + defmodule Rockbox.BluetoothDevice do 2 + @moduledoc "A Bluetooth device known to the host (Linux only)." 3 + 4 + @type t :: %__MODULE__{ 5 + address: String.t(), 6 + name: String.t(), 7 + paired: boolean(), 8 + trusted: boolean(), 9 + connected: boolean(), 10 + rssi: integer() | nil 11 + } 12 + 13 + defstruct [:address, :name, :paired, :trusted, :connected, :rssi] 14 + end
+39
sdk/elixir/lib/rockbox/browse.ex
··· 1 + defmodule Rockbox.Browse do 2 + @moduledoc "Walk the filesystem relative to the configured `music_dir`." 3 + 4 + alias Rockbox.{Client, Entry, Transport, Util} 5 + 6 + @doc "List entries (files + directories) under `path`. `nil` = music_dir root." 7 + @spec entries(Client.t(), String.t() | nil) :: {:ok, [Entry.t()]} | {:error, Exception.t()} 8 + def entries(client, path \\ nil) do 9 + query = ~S""" 10 + query Browse($path: String) { 11 + treeGetEntries(path: $path) { name attr timeWrite customaction displayName } 12 + } 13 + """ 14 + 15 + with {:ok, %{"treeGetEntries" => list}} <- Transport.execute(client, query, %{path: path}) do 16 + {:ok, Util.to_struct_list(Entry, list)} 17 + end 18 + end 19 + 20 + @spec entries!(Client.t(), String.t() | nil) :: [Entry.t()] 21 + def entries!(client, path \\ nil), do: bang(entries(client, path)) 22 + 23 + @doc "Only the directories under `path`." 24 + @spec directories(Client.t(), String.t() | nil) :: {:ok, [Entry.t()]} | {:error, Exception.t()} 25 + def directories(client, path \\ nil) do 26 + with {:ok, entries} <- entries(client, path), 27 + do: {:ok, Enum.filter(entries, &Entry.directory?/1)} 28 + end 29 + 30 + @doc "Only the files under `path`." 31 + @spec files(Client.t(), String.t() | nil) :: {:ok, [Entry.t()]} | {:error, Exception.t()} 32 + def files(client, path \\ nil) do 33 + with {:ok, entries} <- entries(client, path), 34 + do: {:ok, Enum.filter(entries, &Entry.file?/1)} 35 + end 36 + 37 + defp bang({:ok, value}), do: value 38 + defp bang({:error, exception}), do: raise(exception) 39 + end
+68
sdk/elixir/lib/rockbox/client.ex
··· 1 + defmodule Rockbox.Client do 2 + @moduledoc """ 3 + The Rockbox client struct — a stateless handle that holds the URLs and an 4 + optional Req client. Construct with `Rockbox.new/0` or `Rockbox.new/1`. 5 + 6 + All API functions in `Rockbox.*` accept this as their first argument so that 7 + calls compose cleanly with the pipe operator. 8 + 9 + iex> client = Rockbox.new() 10 + iex> client.http_url 11 + "http://localhost:6062/graphql" 12 + """ 13 + 14 + @typedoc """ 15 + Configuration options accepted by `Rockbox.new/1`. 16 + 17 + * `:host` — hostname or IP of rockboxd. Default: `"localhost"`. 18 + * `:port` — GraphQL HTTP/WS port. Default: `6062`. 19 + * `:http_url` — full HTTP URL override (takes precedence over host/port). 20 + * `:ws_url` — full WebSocket URL override (takes precedence over host/port). 21 + * `:headers` — extra HTTP headers (list of `{key, value}` tuples). 22 + * `:timeout` — request timeout in ms. Default: `15_000`. 23 + """ 24 + @type opts :: [ 25 + host: String.t(), 26 + port: non_neg_integer(), 27 + http_url: String.t(), 28 + ws_url: String.t(), 29 + headers: [{String.t(), String.t()}], 30 + timeout: non_neg_integer() 31 + ] 32 + 33 + @type t :: %__MODULE__{ 34 + host: String.t(), 35 + port: non_neg_integer(), 36 + http_url: String.t(), 37 + ws_url: String.t(), 38 + headers: [{String.t(), String.t()}], 39 + timeout: non_neg_integer() 40 + } 41 + 42 + defstruct [ 43 + :host, 44 + :port, 45 + :http_url, 46 + :ws_url, 47 + headers: [], 48 + timeout: 15_000 49 + ] 50 + 51 + @doc "Build a client from options. See `t:opts/0`." 52 + @spec new(opts()) :: t() 53 + def new(opts \\ []) do 54 + host = Keyword.get(opts, :host, "localhost") 55 + port = Keyword.get(opts, :port, 6062) 56 + http_url = Keyword.get(opts, :http_url, "http://#{host}:#{port}/graphql") 57 + ws_url = Keyword.get(opts, :ws_url, "ws://#{host}:#{port}/graphql") 58 + 59 + %__MODULE__{ 60 + host: host, 61 + port: port, 62 + http_url: http_url, 63 + ws_url: ws_url, 64 + headers: Keyword.get(opts, :headers, []), 65 + timeout: Keyword.get(opts, :timeout, 15_000) 66 + } 67 + end 68 + end
+14
sdk/elixir/lib/rockbox/compressor.ex
··· 1 + defmodule Rockbox.Compressor do 2 + @moduledoc "Dynamics compressor settings." 3 + 4 + @type t :: %__MODULE__{ 5 + threshold: integer(), 6 + makeup_gain: integer(), 7 + ratio: integer(), 8 + knee: integer(), 9 + release_time: integer(), 10 + attack_time: integer() 11 + } 12 + 13 + defstruct [:threshold, :makeup_gain, :ratio, :knee, :release_time, :attack_time] 14 + end
+33
sdk/elixir/lib/rockbox/device.ex
··· 1 + defmodule Rockbox.Device do 2 + @moduledoc "A discovered remote output device (Chromecast, AirPlay, …)." 3 + 4 + @type t :: %__MODULE__{ 5 + id: String.t(), 6 + name: String.t(), 7 + host: String.t(), 8 + ip: String.t(), 9 + port: integer(), 10 + service: String.t(), 11 + app: String.t(), 12 + is_connected: boolean(), 13 + base_url: String.t() | nil, 14 + is_cast_device: boolean(), 15 + is_source_device: boolean(), 16 + is_current_device: boolean() 17 + } 18 + 19 + defstruct [ 20 + :id, 21 + :name, 22 + :host, 23 + :ip, 24 + :port, 25 + :service, 26 + :app, 27 + :is_connected, 28 + :base_url, 29 + :is_cast_device, 30 + :is_source_device, 31 + :is_current_device 32 + ] 33 + end
+61
sdk/elixir/lib/rockbox/devices.ex
··· 1 + defmodule Rockbox.Devices do 2 + @moduledoc """ 3 + Discovered remote output sinks (Chromecast, AirPlay receivers, …) advertised 4 + over mDNS. Connecting switches the active PCM output to that device. 5 + """ 6 + 7 + alias Rockbox.{Client, Device, Transport, Util} 8 + 9 + @fields "id name host ip port service app isConnected baseUrl isCastDevice isSourceDevice isCurrentDevice" 10 + 11 + @spec list(Client.t()) :: {:ok, [Device.t()]} | {:error, Exception.t()} 12 + def list(client) do 13 + with {:ok, %{"devices" => list}} <- 14 + Transport.execute(client, "query Devices { devices { #{@fields} } }") do 15 + {:ok, Util.to_struct_list(Device, list)} 16 + end 17 + end 18 + 19 + @spec list!(Client.t()) :: [Device.t()] 20 + def list!(client), do: bang(list(client)) 21 + 22 + @spec get(Client.t(), String.t()) :: {:ok, Device.t() | nil} | {:error, Exception.t()} 23 + def get(client, id) do 24 + with {:ok, %{"device" => raw}} <- 25 + Transport.execute( 26 + client, 27 + "query Device($id: String!) { device(id: $id) { #{@fields} } }", 28 + %{id: id} 29 + ) do 30 + {:ok, Util.to_struct(Device, raw)} 31 + end 32 + end 33 + 34 + @spec connect(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 35 + def connect(client, id), 36 + do: 37 + void( 38 + Transport.execute( 39 + client, 40 + "mutation ConnectDevice($id: String!) { connect(id: $id) }", 41 + %{id: id} 42 + ) 43 + ) 44 + 45 + @spec disconnect(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 46 + def disconnect(client, id), 47 + do: 48 + void( 49 + Transport.execute( 50 + client, 51 + "mutation DisconnectDevice($id: String!) { disconnect(id: $id) }", 52 + %{id: id} 53 + ) 54 + ) 55 + 56 + defp void({:ok, _}), do: :ok 57 + defp void(err), do: err 58 + 59 + defp bang({:ok, value}), do: value 60 + defp bang({:error, exception}), do: raise(exception) 61 + end
+25
sdk/elixir/lib/rockbox/entry.ex
··· 1 + defmodule Rockbox.Entry do 2 + @moduledoc """ 3 + A filesystem entry returned by `Rockbox.Browse`. `:attr` is a bitmask; 4 + use `directory?/1` instead of inspecting it directly. 5 + """ 6 + 7 + @type t :: %__MODULE__{ 8 + name: String.t(), 9 + attr: integer(), 10 + time_write: integer(), 11 + customaction: integer(), 12 + display_name: String.t() | nil 13 + } 14 + 15 + defstruct [:name, :attr, :time_write, :customaction, :display_name] 16 + 17 + @doc "Returns `true` when the entry is a directory (attr bit 4 set)." 18 + @spec directory?(t()) :: boolean() 19 + def directory?(%__MODULE__{attr: attr}) when is_integer(attr), do: Bitwise.band(attr, 0x10) != 0 20 + def directory?(_), do: false 21 + 22 + @doc "Returns `true` when the entry is a regular file." 23 + @spec file?(t()) :: boolean() 24 + def file?(entry), do: not directory?(entry) 25 + end
+11
sdk/elixir/lib/rockbox/eq_band.ex
··· 1 + defmodule Rockbox.EqBand do 2 + @moduledoc "A single equalizer band setting." 3 + 4 + @type t :: %__MODULE__{ 5 + cutoff: integer(), 6 + q: integer(), 7 + gain: integer() 8 + } 9 + 10 + defstruct [:cutoff, :q, :gain] 11 + end
+59
sdk/elixir/lib/rockbox/error.ex
··· 1 + defmodule Rockbox.Error do 2 + @moduledoc """ 3 + Base exception for the Rockbox SDK. 4 + 5 + Two specialised subclasses are raised in practice: 6 + 7 + * `Rockbox.NetworkError` — the HTTP request failed or returned a non-2xx 8 + status code (rockboxd unreachable, refused connection, timeout, …). 9 + * `Rockbox.GraphQLError` — rockboxd answered with a structured GraphQL 10 + error response. The original error list is available on the 11 + `:errors` field. 12 + 13 + Both inherit from `Rockbox.Error`, so a single rescue clause catches every 14 + SDK-originated failure: 15 + 16 + try do 17 + Rockbox.Playback.play!(client) 18 + rescue 19 + e in Rockbox.NetworkError -> Logger.error("offline: \#{e.message}") 20 + e in Rockbox.GraphQLError -> Logger.error("server error: \#{e.message}") 21 + e in Rockbox.Error -> Logger.error("rockbox: \#{e.message}") 22 + end 23 + """ 24 + 25 + defexception [:message, :cause] 26 + 27 + @type t :: %__MODULE__{message: String.t(), cause: term() | nil} 28 + end 29 + 30 + defmodule Rockbox.NetworkError do 31 + @moduledoc "Raised when the HTTP layer cannot reach rockboxd." 32 + 33 + defexception [:message, :cause] 34 + 35 + @type t :: %__MODULE__{message: String.t(), cause: term() | nil} 36 + end 37 + 38 + defmodule Rockbox.GraphQLError do 39 + @moduledoc "Raised when rockboxd returns a structured GraphQL error response." 40 + 41 + defexception [:message, :errors] 42 + 43 + @type error_detail :: %{ 44 + required(:message) => String.t(), 45 + optional(:path) => [String.t() | non_neg_integer()], 46 + optional(:locations) => [%{line: non_neg_integer(), column: non_neg_integer()}], 47 + optional(:extensions) => map() 48 + } 49 + 50 + @type t :: %__MODULE__{message: String.t(), errors: [error_detail()]} 51 + 52 + @impl true 53 + def exception(errors) when is_list(errors) do 54 + %__MODULE__{ 55 + message: errors |> Enum.map_join("; ", &Map.get(&1, :message, "GraphQL error")), 56 + errors: errors 57 + } 58 + end 59 + end
+82
sdk/elixir/lib/rockbox/events.ex
··· 1 + defmodule Rockbox.Events do 2 + @moduledoc """ 3 + Subscribe the calling process to real-time events emitted by rockboxd. 4 + 5 + Events arrive as plain Erlang messages so they integrate naturally with 6 + `receive` blocks and `GenServer.handle_info/2`: 7 + 8 + {:ok, _pid} = Rockbox.connect(client) 9 + :ok = Rockbox.Events.subscribe(:track_changed) 10 + 11 + receive do 12 + {:rockbox, :track_changed, %Rockbox.Track{} = track} -> 13 + IO.puts("Now playing: \#{track.title}") 14 + end 15 + 16 + ## Event names and payloads 17 + 18 + | event | payload | 19 + |--------------------|--------------------------------------------| 20 + | `:track_changed` | `Rockbox.Track.t()` | 21 + | `:status_changed` | `:stopped | :playing | :paused` | 22 + | `:playlist_changed`| `Rockbox.Playlist.t()` | 23 + | `:ws_open` | `nil` | 24 + | `:ws_close` | `nil` | 25 + | `:ws_error` | `Exception.t()` | 26 + 27 + Subscribers are auto-removed when their process exits, so you never need to 28 + manually clean up. 29 + """ 30 + 31 + @typedoc "All event names emitted by the SDK." 32 + @type event :: 33 + :track_changed 34 + | :status_changed 35 + | :playlist_changed 36 + | :ws_open 37 + | :ws_close 38 + | :ws_error 39 + 40 + @typedoc "All event names plus `:all` for catch-all subscribers." 41 + @type subscription :: event() | :all 42 + 43 + @doc """ 44 + Subscribe the calling process to one or more events. 45 + 46 + Pass a single atom (or `:all` for catch-all), or a list of atoms. 47 + """ 48 + @spec subscribe(subscription() | [subscription()]) :: :ok 49 + def subscribe(event) when is_atom(event) do 50 + {:ok, _} = Registry.register(Rockbox.Subscribers, event, []) 51 + :ok 52 + end 53 + 54 + def subscribe(events) when is_list(events) do 55 + Enum.each(events, &subscribe/1) 56 + end 57 + 58 + @doc "Unsubscribe the calling process from an event." 59 + @spec unsubscribe(subscription()) :: :ok 60 + def unsubscribe(event) when is_atom(event) do 61 + Registry.unregister(Rockbox.Subscribers, event) 62 + end 63 + 64 + @doc false 65 + @spec broadcast(event(), term()) :: :ok 66 + def broadcast(event, payload) do 67 + deliver(event, payload) 68 + deliver(:all, {event, payload}) 69 + :ok 70 + end 71 + 72 + defp deliver(key, payload) do 73 + Registry.dispatch(Rockbox.Subscribers, key, fn entries -> 74 + for {pid, _} <- entries do 75 + send(pid, message(key, payload)) 76 + end 77 + end) 78 + end 79 + 80 + defp message(:all, {event, payload}), do: {:rockbox, event, payload} 81 + defp message(event, payload), do: {:rockbox, event, payload} 82 + end
+285
sdk/elixir/lib/rockbox/library.ex
··· 1 + defmodule Rockbox.Library do 2 + @moduledoc """ 3 + Browse and manage the music library — albums, artists, tracks, likes, 4 + search, and rescan. 5 + """ 6 + 7 + alias Rockbox.{Album, Artist, Client, SearchResults, Track, Transport, Util} 8 + 9 + @track_fields ~S""" 10 + fragment TrackFields on Track { 11 + id title artist album genre disc trackString yearString 12 + composer comment albumArtist grouping 13 + discnum tracknum layer year bitrate frequency 14 + filesize length elapsed path 15 + albumId artistId genreId albumArt 16 + } 17 + """ 18 + 19 + @album_fields ~S""" 20 + fragment AlbumFields on Album { 21 + id title artist year yearString albumArt md5 artistId copyrightMessage 22 + } 23 + """ 24 + 25 + @artist_fields ~S""" 26 + fragment ArtistFields on Artist { 27 + id name bio image 28 + } 29 + """ 30 + 31 + # --------------------------------------------------------------------------- 32 + # Albums 33 + # --------------------------------------------------------------------------- 34 + 35 + @doc "List every album. Each album's `:tracks` are stub records (id/title/path/length/album_art)." 36 + @spec albums(Client.t()) :: {:ok, [Album.t()]} | {:error, Exception.t()} 37 + def albums(client) do 38 + query = 39 + @album_fields <> 40 + "query Albums { albums { ...AlbumFields tracks { id title path length albumArt } } }" 41 + 42 + with {:ok, %{"albums" => list}} <- Transport.execute(client, query) do 43 + {:ok, Enum.map(list, &album_with_tracks/1)} 44 + end 45 + end 46 + 47 + @spec albums!(Client.t()) :: [Album.t()] 48 + def albums!(client), do: bang(albums(client)) 49 + 50 + @doc "Get a single album with full track info, or `{:ok, nil}`." 51 + @spec album(Client.t(), String.t()) :: {:ok, Album.t() | nil} | {:error, Exception.t()} 52 + def album(client, id) do 53 + query = 54 + @track_fields <> 55 + @album_fields <> 56 + "query Album($id: String!) { album(id: $id) { ...AlbumFields tracks { ...TrackFields } } }" 57 + 58 + with {:ok, %{"album" => raw}} <- Transport.execute(client, query, %{id: id}) do 59 + {:ok, album_with_tracks(raw)} 60 + end 61 + end 62 + 63 + @spec album!(Client.t(), String.t()) :: Album.t() | nil 64 + def album!(client, id), do: bang(album(client, id)) 65 + 66 + @doc "List albums the user has liked." 67 + @spec liked_albums(Client.t()) :: {:ok, [Album.t()]} | {:error, Exception.t()} 68 + def liked_albums(client) do 69 + query = @album_fields <> "query LikedAlbums { likedAlbums { ...AlbumFields } }" 70 + 71 + with {:ok, %{"likedAlbums" => list}} <- Transport.execute(client, query) do 72 + {:ok, Util.to_struct_list(Album, list)} 73 + end 74 + end 75 + 76 + @spec like_album(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 77 + def like_album(client, id), 78 + do: 79 + void( 80 + Transport.execute(client, "mutation LikeAlbum($id: String!) { likeAlbum(id: $id) }", %{ 81 + id: id 82 + }) 83 + ) 84 + 85 + @spec unlike_album(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 86 + def unlike_album(client, id), 87 + do: 88 + void( 89 + Transport.execute( 90 + client, 91 + "mutation UnlikeAlbum($id: String!) { unlikeAlbum(id: $id) }", 92 + %{ 93 + id: id 94 + } 95 + ) 96 + ) 97 + 98 + # --------------------------------------------------------------------------- 99 + # Artists 100 + # --------------------------------------------------------------------------- 101 + 102 + @doc "List every artist with shallow album info." 103 + @spec artists(Client.t()) :: {:ok, [Artist.t()]} | {:error, Exception.t()} 104 + def artists(client) do 105 + query = 106 + @artist_fields <> 107 + "query Artists { artists { ...ArtistFields albums { id title albumArt year } } }" 108 + 109 + with {:ok, %{"artists" => list}} <- Transport.execute(client, query) do 110 + {:ok, Enum.map(list, &artist_with_albums/1)} 111 + end 112 + end 113 + 114 + @spec artists!(Client.t()) :: [Artist.t()] 115 + def artists!(client), do: bang(artists(client)) 116 + 117 + @doc "Get a single artist with their albums and tracks." 118 + @spec artist(Client.t(), String.t()) :: {:ok, Artist.t() | nil} | {:error, Exception.t()} 119 + def artist(client, id) do 120 + query = 121 + @artist_fields <> 122 + @track_fields <> 123 + """ 124 + query Artist($id: String!) { 125 + artist(id: $id) { 126 + ...ArtistFields 127 + albums { id title albumArt year yearString md5 artistId tracks { id title path length } } 128 + tracks { ...TrackFields } 129 + } 130 + } 131 + """ 132 + 133 + with {:ok, %{"artist" => raw}} <- Transport.execute(client, query, %{id: id}) do 134 + {:ok, artist_with_albums(raw)} 135 + end 136 + end 137 + 138 + @spec artist!(Client.t(), String.t()) :: Artist.t() | nil 139 + def artist!(client, id), do: bang(artist(client, id)) 140 + 141 + # --------------------------------------------------------------------------- 142 + # Tracks 143 + # --------------------------------------------------------------------------- 144 + 145 + @doc "List every track." 146 + @spec tracks(Client.t()) :: {:ok, [Track.t()]} | {:error, Exception.t()} 147 + def tracks(client) do 148 + query = @track_fields <> "query Tracks { tracks { ...TrackFields } }" 149 + 150 + with {:ok, %{"tracks" => list}} <- Transport.execute(client, query) do 151 + {:ok, Util.to_struct_list(Track, list)} 152 + end 153 + end 154 + 155 + @spec tracks!(Client.t()) :: [Track.t()] 156 + def tracks!(client), do: bang(tracks(client)) 157 + 158 + @doc "Get a single track by id, or `{:ok, nil}`." 159 + @spec track(Client.t(), String.t()) :: {:ok, Track.t() | nil} | {:error, Exception.t()} 160 + def track(client, id) do 161 + query = @track_fields <> "query Track($id: String!) { track(id: $id) { ...TrackFields } }" 162 + 163 + with {:ok, %{"track" => raw}} <- Transport.execute(client, query, %{id: id}) do 164 + {:ok, Util.to_struct(Track, raw)} 165 + end 166 + end 167 + 168 + @spec track!(Client.t(), String.t()) :: Track.t() | nil 169 + def track!(client, id), do: bang(track(client, id)) 170 + 171 + @doc "List tracks the user has liked." 172 + @spec liked_tracks(Client.t()) :: {:ok, [Track.t()]} | {:error, Exception.t()} 173 + def liked_tracks(client) do 174 + query = @track_fields <> "query LikedTracks { likedTracks { ...TrackFields } }" 175 + 176 + with {:ok, %{"likedTracks" => list}} <- Transport.execute(client, query) do 177 + {:ok, Util.to_struct_list(Track, list)} 178 + end 179 + end 180 + 181 + @spec like_track(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 182 + def like_track(client, id), 183 + do: 184 + void( 185 + Transport.execute(client, "mutation LikeTrack($id: String!) { likeTrack(id: $id) }", %{ 186 + id: id 187 + }) 188 + ) 189 + 190 + @spec unlike_track(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 191 + def unlike_track(client, id), 192 + do: 193 + void( 194 + Transport.execute( 195 + client, 196 + "mutation UnlikeTrack($id: String!) { unlikeTrack(id: $id) }", 197 + %{ 198 + id: id 199 + } 200 + ) 201 + ) 202 + 203 + # --------------------------------------------------------------------------- 204 + # Search 205 + # --------------------------------------------------------------------------- 206 + 207 + @doc "Full-text search across artists, albums and tracks." 208 + @spec search(Client.t(), String.t()) :: {:ok, SearchResults.t()} | {:error, Exception.t()} 209 + def search(client, term) do 210 + query = 211 + @track_fields <> 212 + @album_fields <> 213 + @artist_fields <> 214 + """ 215 + query Search($term: String!) { 216 + search(term: $term) { 217 + artists { ...ArtistFields } 218 + albums { ...AlbumFields } 219 + tracks { ...TrackFields } 220 + likedTracks { ...TrackFields } 221 + likedAlbums { ...AlbumFields } 222 + } 223 + } 224 + """ 225 + 226 + with {:ok, %{"search" => raw}} <- Transport.execute(client, query, %{term: term}) do 227 + atomized = Util.atomize(raw) 228 + 229 + {:ok, 230 + %SearchResults{ 231 + artists: Util.to_struct_list(Artist, atomized.artists), 232 + albums: Util.to_struct_list(Album, atomized.albums), 233 + tracks: Util.to_struct_list(Track, atomized.tracks), 234 + liked_tracks: Util.to_struct_list(Track, atomized.liked_tracks), 235 + liked_albums: Util.to_struct_list(Album, atomized.liked_albums) 236 + }} 237 + end 238 + end 239 + 240 + @spec search!(Client.t(), String.t()) :: SearchResults.t() 241 + def search!(client, term), do: bang(search(client, term)) 242 + 243 + # --------------------------------------------------------------------------- 244 + # Library management 245 + # --------------------------------------------------------------------------- 246 + 247 + @doc "Trigger a full rescan of the configured `music_dir`." 248 + @spec scan(Client.t()) :: :ok | {:error, Exception.t()} 249 + def scan(client), 250 + do: void(Transport.execute(client, "mutation ScanLibrary { scanLibrary }")) 251 + 252 + # --------------------------------------------------------------------------- 253 + # Internal 254 + # --------------------------------------------------------------------------- 255 + 256 + defp album_with_tracks(nil), do: nil 257 + 258 + defp album_with_tracks(raw) do 259 + atomized = Util.atomize(raw) 260 + base = Util.to_struct(Album, raw) 261 + %{base | tracks: Util.to_struct_list(Track, Map.get(atomized, :tracks, []))} 262 + end 263 + 264 + defp artist_with_albums(nil), do: nil 265 + 266 + defp artist_with_albums(raw) do 267 + atomized = Util.atomize(raw) 268 + base = Util.to_struct(Artist, raw) 269 + 270 + albums = 271 + atomized 272 + |> Map.get(:albums, []) 273 + |> Enum.map(&album_with_tracks/1) 274 + 275 + tracks = Util.to_struct_list(Track, Map.get(atomized, :tracks, [])) 276 + %{base | albums: albums, tracks: tracks} 277 + end 278 + 279 + defp void({:ok, _}), do: :ok 280 + defp void(err), do: err 281 + 282 + defp bang({:ok, value}), do: value 283 + defp bang(:ok), do: :ok 284 + defp bang({:error, exception}), do: raise(exception) 285 + end
+278
sdk/elixir/lib/rockbox/playback.ex
··· 1 + defmodule Rockbox.Playback do 2 + @moduledoc """ 3 + Transport controls and play helpers. 4 + 5 + Rockbox.new() 6 + |> Rockbox.Playback.play_album("album-id", shuffle: true) 7 + 8 + Most functions return `:ok | {:error, exception}`. A `!` variant raises on 9 + error. Functions that return data come in `name/1 → {:ok, value}` and 10 + `name!/1 → value` pairs. 11 + """ 12 + 13 + alias Rockbox.{Client, Track, Transport, Types, Util} 14 + 15 + @track_fields ~S""" 16 + fragment TrackFields on Track { 17 + id title artist album genre disc trackString yearString 18 + composer comment albumArtist grouping 19 + discnum tracknum layer year bitrate frequency 20 + filesize length elapsed path 21 + albumId artistId genreId albumArt 22 + } 23 + """ 24 + 25 + # --------------------------------------------------------------------------- 26 + # State 27 + # --------------------------------------------------------------------------- 28 + 29 + @doc """ 30 + Current playback status as an atom (`:stopped | :playing | :paused`). 31 + 32 + iex> Rockbox.Playback.status(client) 33 + {:ok, :playing} 34 + """ 35 + @spec status(Client.t()) :: {:ok, Types.playback_status()} | {:error, Exception.t()} 36 + def status(client) do 37 + case Transport.execute(client, "query PlaybackStatus { status }") do 38 + {:ok, %{"status" => raw}} -> {:ok, Types.playback_status(raw)} 39 + err -> err 40 + end 41 + end 42 + 43 + @spec status!(Client.t()) :: Types.playback_status() 44 + def status!(client), do: bang(status(client)) 45 + 46 + @doc "Raw integer playback status (matches the firmware enum)." 47 + @spec raw_status(Client.t()) :: {:ok, integer()} | {:error, Exception.t()} 48 + def raw_status(client) do 49 + case Transport.execute(client, "query PlaybackStatus { status }") do 50 + {:ok, %{"status" => raw}} -> {:ok, raw} 51 + err -> err 52 + end 53 + end 54 + 55 + @doc "The currently playing track, or `{:ok, nil}` when stopped." 56 + @spec current_track(Client.t()) :: {:ok, Track.t() | nil} | {:error, Exception.t()} 57 + def current_track(client) do 58 + query = @track_fields <> "query CurrentTrack { currentTrack { ...TrackFields } }" 59 + 60 + case Transport.execute(client, query) do 61 + {:ok, %{"currentTrack" => raw}} -> {:ok, Util.to_struct(Track, raw)} 62 + err -> err 63 + end 64 + end 65 + 66 + @spec current_track!(Client.t()) :: Track.t() | nil 67 + def current_track!(client), do: bang(current_track(client)) 68 + 69 + @doc "The next track in the queue, or `{:ok, nil}` if there is none." 70 + @spec next_track(Client.t()) :: {:ok, Track.t() | nil} | {:error, Exception.t()} 71 + def next_track(client) do 72 + query = @track_fields <> "query NextTrack { nextTrack { ...TrackFields } }" 73 + 74 + case Transport.execute(client, query) do 75 + {:ok, %{"nextTrack" => raw}} -> {:ok, Util.to_struct(Track, raw)} 76 + err -> err 77 + end 78 + end 79 + 80 + @spec next_track!(Client.t()) :: Track.t() | nil 81 + def next_track!(client), do: bang(next_track(client)) 82 + 83 + @doc "Byte offset into the currently playing file." 84 + @spec file_position(Client.t()) :: {:ok, integer()} | {:error, Exception.t()} 85 + def file_position(client) do 86 + case Transport.execute(client, "query FilePosition { getFilePosition }") do 87 + {:ok, %{"getFilePosition" => pos}} -> {:ok, pos} 88 + err -> err 89 + end 90 + end 91 + 92 + # --------------------------------------------------------------------------- 93 + # Transport controls 94 + # --------------------------------------------------------------------------- 95 + 96 + @doc "Resume playback from the queued position." 97 + @spec play(Client.t(), keyword()) :: :ok | {:error, Exception.t()} 98 + def play(client, opts \\ []) do 99 + elapsed = Keyword.get(opts, :elapsed, 0) 100 + offset = Keyword.get(opts, :offset, 0) 101 + 102 + void( 103 + Transport.execute( 104 + client, 105 + "mutation Play($elapsed: Long!, $offset: Long!) { play(elapsed: $elapsed, offset: $offset) }", 106 + %{elapsed: elapsed, offset: offset} 107 + ) 108 + ) 109 + end 110 + 111 + @spec play!(Client.t(), keyword()) :: :ok 112 + def play!(client, opts \\ []), do: bang(play(client, opts)) 113 + 114 + for {fun, mutation} <- [ 115 + pause: "mutation Pause { pause }", 116 + resume: "mutation Resume { resume }", 117 + next: "mutation Next { next }", 118 + previous: "mutation Previous { previous }", 119 + stop: "mutation Stop { hardStop }", 120 + flush_and_reload: "mutation FlushReload { flushAndReloadTracks }" 121 + ] do 122 + @doc "Run the corresponding mutation." 123 + @spec unquote(fun)(Client.t()) :: :ok | {:error, Exception.t()} 124 + def unquote(fun)(client), do: void(Transport.execute(client, unquote(mutation))) 125 + 126 + bang_name = String.to_atom("#{fun}!") 127 + @doc false 128 + @spec unquote(bang_name)(Client.t()) :: :ok 129 + def unquote(bang_name)(client), do: bang(unquote(fun)(client)) 130 + end 131 + 132 + @doc "Seek to an absolute position in milliseconds." 133 + @spec seek(Client.t(), integer()) :: :ok | {:error, Exception.t()} 134 + def seek(client, position_ms) when is_integer(position_ms) do 135 + void( 136 + Transport.execute( 137 + client, 138 + "mutation Seek($newTime: Int!) { fastForwardRewind(newTime: $newTime) }", 139 + %{new_time: position_ms} 140 + ) 141 + ) 142 + end 143 + 144 + @spec seek!(Client.t(), integer()) :: :ok 145 + def seek!(client, ms), do: bang(seek(client, ms)) 146 + 147 + # --------------------------------------------------------------------------- 148 + # Play helpers (single-call shortcuts) 149 + # --------------------------------------------------------------------------- 150 + 151 + @doc "Play a single file by absolute path." 152 + @spec play_track(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 153 + def play_track(client, path) do 154 + void( 155 + Transport.execute( 156 + client, 157 + "mutation PlayTrack($path: String!) { playTrack(path: $path) }", 158 + %{path: path} 159 + ) 160 + ) 161 + end 162 + 163 + @spec play_track!(Client.t(), String.t()) :: :ok 164 + def play_track!(client, path), do: bang(play_track(client, path)) 165 + 166 + @doc """ 167 + Play all tracks from an album. 168 + 169 + Options: `:shuffle`, `:position` (start track index). 170 + """ 171 + @spec play_album(Client.t(), String.t(), keyword()) :: :ok | {:error, Exception.t()} 172 + def play_album(client, album_id, opts \\ []) do 173 + vars = Map.merge(%{album_id: album_id}, Map.new(opts)) 174 + 175 + void( 176 + Transport.execute( 177 + client, 178 + "mutation PlayAlbum($albumId: String!, $shuffle: Boolean, $position: Int) { playAlbum(albumId: $albumId, shuffle: $shuffle, position: $position) }", 179 + vars 180 + ) 181 + ) 182 + end 183 + 184 + @spec play_album!(Client.t(), String.t(), keyword()) :: :ok 185 + def play_album!(client, id, opts \\ []), do: bang(play_album(client, id, opts)) 186 + 187 + @doc "Play all tracks by an artist. Options: `:shuffle`, `:position`." 188 + @spec play_artist(Client.t(), String.t(), keyword()) :: :ok | {:error, Exception.t()} 189 + def play_artist(client, artist_id, opts \\ []) do 190 + vars = Map.merge(%{artist_id: artist_id}, Map.new(opts)) 191 + 192 + void( 193 + Transport.execute( 194 + client, 195 + "mutation PlayArtist($artistId: String!, $shuffle: Boolean, $position: Int) { playArtistTracks(artistId: $artistId, shuffle: $shuffle, position: $position) }", 196 + vars 197 + ) 198 + ) 199 + end 200 + 201 + @spec play_artist!(Client.t(), String.t(), keyword()) :: :ok 202 + def play_artist!(client, id, opts \\ []), do: bang(play_artist(client, id, opts)) 203 + 204 + @doc "Play a saved playlist by id. Options: `:shuffle`, `:position`." 205 + @spec play_playlist(Client.t(), String.t(), keyword()) :: :ok | {:error, Exception.t()} 206 + def play_playlist(client, playlist_id, opts \\ []) do 207 + vars = Map.merge(%{playlist_id: playlist_id}, Map.new(opts)) 208 + 209 + void( 210 + Transport.execute( 211 + client, 212 + "mutation PlayPlaylist($playlistId: String!, $shuffle: Boolean, $position: Int) { playPlaylist(playlistId: $playlistId, shuffle: $shuffle, position: $position) }", 213 + vars 214 + ) 215 + ) 216 + end 217 + 218 + @spec play_playlist!(Client.t(), String.t(), keyword()) :: :ok 219 + def play_playlist!(client, id, opts \\ []), do: bang(play_playlist(client, id, opts)) 220 + 221 + @doc "Play every file under a directory. Options: `:recurse`, `:shuffle`, `:position`." 222 + @spec play_directory(Client.t(), String.t(), keyword()) :: :ok | {:error, Exception.t()} 223 + def play_directory(client, path, opts \\ []) do 224 + vars = Map.merge(%{path: path}, Map.new(opts)) 225 + 226 + void( 227 + Transport.execute( 228 + client, 229 + "mutation PlayDirectory($path: String!, $recurse: Boolean, $shuffle: Boolean, $position: Int) { playDirectory(path: $path, recurse: $recurse, shuffle: $shuffle, position: $position) }", 230 + vars 231 + ) 232 + ) 233 + end 234 + 235 + @spec play_directory!(Client.t(), String.t(), keyword()) :: :ok 236 + def play_directory!(client, path, opts \\ []), do: bang(play_directory(client, path, opts)) 237 + 238 + @doc "Play the user's liked tracks." 239 + @spec play_liked_tracks(Client.t(), keyword()) :: :ok | {:error, Exception.t()} 240 + def play_liked_tracks(client, opts \\ []) do 241 + void( 242 + Transport.execute( 243 + client, 244 + "mutation PlayLikedTracks($shuffle: Boolean, $position: Int) { playLikedTracks(shuffle: $shuffle, position: $position) }", 245 + Map.new(opts) 246 + ) 247 + ) 248 + end 249 + 250 + @spec play_liked_tracks!(Client.t(), keyword()) :: :ok 251 + def play_liked_tracks!(client, opts \\ []), do: bang(play_liked_tracks(client, opts)) 252 + 253 + @doc "Play the entire library — typically with `shuffle: true`." 254 + @spec play_all_tracks(Client.t(), keyword()) :: :ok | {:error, Exception.t()} 255 + def play_all_tracks(client, opts \\ []) do 256 + void( 257 + Transport.execute( 258 + client, 259 + "mutation PlayAllTracks($shuffle: Boolean, $position: Int) { playAllTracks(shuffle: $shuffle, position: $position) }", 260 + Map.new(opts) 261 + ) 262 + ) 263 + end 264 + 265 + @spec play_all_tracks!(Client.t(), keyword()) :: :ok 266 + def play_all_tracks!(client, opts \\ []), do: bang(play_all_tracks(client, opts)) 267 + 268 + # --------------------------------------------------------------------------- 269 + # Internal 270 + # --------------------------------------------------------------------------- 271 + 272 + defp void({:ok, _}), do: :ok 273 + defp void(err), do: err 274 + 275 + defp bang({:ok, value}), do: value 276 + defp bang(:ok), do: :ok 277 + defp bang({:error, exception}), do: raise(exception) 278 + end
+35
sdk/elixir/lib/rockbox/playlist.ex
··· 1 + defmodule Rockbox.Playlist do 2 + @moduledoc """ 3 + A snapshot of the live playback queue. `:index` is the 0-based position of 4 + the currently playing track in `:tracks`. 5 + """ 6 + 7 + @type t :: %__MODULE__{ 8 + amount: integer(), 9 + index: integer(), 10 + max_playlist_size: integer(), 11 + first_index: integer(), 12 + last_insert_pos: integer(), 13 + seed: integer(), 14 + last_shuffled_start: integer(), 15 + tracks: [Rockbox.Track.t()] 16 + } 17 + 18 + defstruct [ 19 + :amount, 20 + :index, 21 + :max_playlist_size, 22 + :first_index, 23 + :last_insert_pos, 24 + :seed, 25 + :last_shuffled_start, 26 + tracks: [] 27 + ] 28 + 29 + @doc "The currently playing track, or `nil` if the queue is empty." 30 + @spec current_track(t()) :: Rockbox.Track.t() | nil 31 + def current_track(%__MODULE__{tracks: tracks, index: i}) when is_list(tracks) and i >= 0, 32 + do: Enum.at(tracks, i) 33 + 34 + def current_track(_), do: nil 35 + end
+55
sdk/elixir/lib/rockbox/plugin.ex
··· 1 + defmodule Rockbox.Plugin do 2 + @moduledoc """ 3 + Behaviour for Rockbox plugins. A plugin is a regular module implementing 4 + `install/1` (and optionally `uninstall/1`). 5 + 6 + defmodule MyApp.LastFmScrobbler do 7 + @behaviour Rockbox.Plugin 8 + 9 + @impl true 10 + def name, do: "lastfm-scrobbler" 11 + @impl true 12 + def version, do: "1.0.0" 13 + @impl true 14 + def description, do: "Scrobble played tracks to Last.fm" 15 + 16 + @impl true 17 + def install(ctx) do 18 + # Subscribe like any other process — events arrive as messages. 19 + Rockbox.Events.subscribe(:track_changed) 20 + {:ok, %{client: ctx.client, started_at: System.monotonic_time(:millisecond)}} 21 + end 22 + 23 + @impl true 24 + def uninstall(_state), do: :ok 25 + end 26 + 27 + Install with `Rockbox.use_plugin(client, MyApp.LastFmScrobbler)`. Plugins 28 + receive a `t:context/0` map containing the client they were installed with. 29 + 30 + Heavy plugins (those that need their own process) should spawn it inside 31 + `install/1` and store its pid in their state — `uninstall/1` will be called 32 + with that state and can shut the process down. 33 + """ 34 + 35 + @type context :: %{client: Rockbox.Client.t()} 36 + @type state :: term() 37 + 38 + @callback name() :: String.t() 39 + @callback version() :: String.t() 40 + @callback description() :: String.t() 41 + @callback install(context()) :: {:ok, state()} | :ok | {:error, term()} 42 + @callback uninstall(state()) :: :ok | {:error, term()} 43 + 44 + @optional_callbacks description: 0, uninstall: 1 45 + 46 + @doc false 47 + def description(plugin) do 48 + if function_exported?(plugin, :description, 0), do: plugin.description(), else: nil 49 + end 50 + 51 + @doc false 52 + def uninstall(plugin, state) do 53 + if function_exported?(plugin, :uninstall, 1), do: plugin.uninstall(state), else: :ok 54 + end 55 + end
+81
sdk/elixir/lib/rockbox/plugins.ex
··· 1 + defmodule Rockbox.Plugins do 2 + @moduledoc """ 3 + Registry of installed `Rockbox.Plugin` modules. You normally interact with 4 + it through `Rockbox.use_plugin/2`, `Rockbox.unuse_plugin/2`, and 5 + `Rockbox.installed_plugins/0`. 6 + """ 7 + 8 + use GenServer 9 + 10 + alias Rockbox.{Client, Plugin} 11 + 12 + @type entry :: %{module: module(), state: term(), client: Client.t()} 13 + 14 + # --------------------------------------------------------------------------- 15 + # Public API 16 + # --------------------------------------------------------------------------- 17 + 18 + @spec start_link(term()) :: GenServer.on_start() 19 + def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__) 20 + 21 + @spec install(Client.t(), module()) :: :ok | {:error, term()} 22 + def install(client, plugin), do: GenServer.call(__MODULE__, {:install, client, plugin}) 23 + 24 + @spec uninstall(module() | String.t()) :: :ok 25 + def uninstall(name_or_module), do: GenServer.call(__MODULE__, {:uninstall, name_or_module}) 26 + 27 + @spec list() :: [entry()] 28 + def list, do: GenServer.call(__MODULE__, :list) 29 + 30 + # --------------------------------------------------------------------------- 31 + # GenServer 32 + # --------------------------------------------------------------------------- 33 + 34 + @impl true 35 + def init(_), do: {:ok, %{}} 36 + 37 + @impl true 38 + def handle_call({:install, client, plugin}, _from, state) do 39 + name = plugin.name() 40 + 41 + if Map.has_key?(state, name) do 42 + {:reply, {:error, :already_installed}, state} 43 + else 44 + ctx = %{client: client} 45 + 46 + case plugin.install(ctx) do 47 + {:ok, plugin_state} -> 48 + entry = %{module: plugin, state: plugin_state, client: client} 49 + {:reply, :ok, Map.put(state, name, entry)} 50 + 51 + :ok -> 52 + entry = %{module: plugin, state: nil, client: client} 53 + {:reply, :ok, Map.put(state, name, entry)} 54 + 55 + {:error, _} = err -> 56 + {:reply, err, state} 57 + end 58 + end 59 + end 60 + 61 + def handle_call({:uninstall, key}, _from, state) do 62 + name = if is_atom(key), do: try_name(key, key), else: key 63 + 64 + case Map.pop(state, name) do 65 + {nil, state} -> 66 + {:reply, :ok, state} 67 + 68 + {%{module: mod, state: pstate}, state} -> 69 + Plugin.uninstall(mod, pstate) 70 + {:reply, :ok, state} 71 + end 72 + end 73 + 74 + def handle_call(:list, _from, state) do 75 + {:reply, Map.values(state), state} 76 + end 77 + 78 + defp try_name(plugin, fallback) do 79 + if function_exported?(plugin, :name, 0), do: plugin.name(), else: to_string(fallback) 80 + end 81 + end
+178
sdk/elixir/lib/rockbox/queue.ex
··· 1 + defmodule Rockbox.Queue do 2 + @moduledoc """ 3 + The live playback queue (called *playlist* in the GraphQL schema). For 4 + persistent named collections see `Rockbox.SavedPlaylists`. 5 + 6 + Rockbox.new() 7 + |> Rockbox.Queue.insert_tracks(["/Music/a.mp3", "/Music/b.mp3"], :next) 8 + """ 9 + 10 + alias Rockbox.{Client, Playlist, Track, Transport, Types, Util} 11 + 12 + @track_fields ~S""" 13 + fragment TrackFields on Track { 14 + id title artist album genre disc trackString yearString 15 + composer comment albumArtist grouping 16 + discnum tracknum layer year bitrate frequency 17 + filesize length elapsed path 18 + albumId artistId genreId albumArt 19 + } 20 + """ 21 + 22 + @doc "Snapshot of the active queue." 23 + @spec current(Client.t()) :: {:ok, Playlist.t()} | {:error, Exception.t()} 24 + def current(client) do 25 + query = 26 + @track_fields <> 27 + """ 28 + query CurrentPlaylist { 29 + playlistGetCurrent { 30 + amount index maxPlaylistSize firstIndex 31 + lastInsertPos seed lastShuffledStart 32 + tracks { ...TrackFields } 33 + } 34 + } 35 + """ 36 + 37 + with {:ok, %{"playlistGetCurrent" => raw}} <- Transport.execute(client, query) do 38 + atomized = Util.atomize(raw) 39 + base = Util.to_struct(Playlist, raw) 40 + {:ok, %{base | tracks: Util.to_struct_list(Track, Map.get(atomized, :tracks, []))}} 41 + end 42 + end 43 + 44 + @spec current!(Client.t()) :: Playlist.t() 45 + def current!(client), do: bang(current(client)) 46 + 47 + @doc "Number of tracks currently queued." 48 + @spec amount(Client.t()) :: {:ok, integer()} | {:error, Exception.t()} 49 + def amount(client) do 50 + case Transport.execute(client, "query PlaylistAmount { playlistAmount }") do 51 + {:ok, %{"playlistAmount" => n}} -> {:ok, n} 52 + err -> err 53 + end 54 + end 55 + 56 + # --------------------------------------------------------------------------- 57 + # Insert / remove 58 + # --------------------------------------------------------------------------- 59 + 60 + @doc """ 61 + Insert one or more file paths (or track ids) into the queue. 62 + 63 + `position` may be an atom (`:next`, `:after_current`, `:last`, `:first`) or 64 + the matching integer. `playlist_id` is optional — omit to target the active 65 + queue. 66 + 67 + Rockbox.Queue.insert_tracks(client, ["/Music/a.mp3"], :next) 68 + """ 69 + @spec insert_tracks(Client.t(), [String.t()], Types.insert_position(), String.t() | nil) :: 70 + :ok | {:error, Exception.t()} 71 + def insert_tracks(client, paths, position \\ :next, playlist_id \\ nil) do 72 + vars = %{playlist_id: playlist_id, position: Types.insert_position(position), tracks: paths} 73 + 74 + void( 75 + Transport.execute( 76 + client, 77 + "mutation InsertTracks($playlistId: String, $position: Int!, $tracks: [String!]!) { insertTracks(playlistId: $playlistId, position: $position, tracks: $tracks) }", 78 + vars 79 + ) 80 + ) 81 + end 82 + 83 + @doc "Insert every file under a directory into the queue." 84 + @spec insert_directory(Client.t(), String.t(), Types.insert_position(), String.t() | nil) :: 85 + :ok | {:error, Exception.t()} 86 + def insert_directory(client, directory, position \\ :last, playlist_id \\ nil) do 87 + vars = %{ 88 + playlist_id: playlist_id, 89 + position: Types.insert_position(position), 90 + directory: directory 91 + } 92 + 93 + void( 94 + Transport.execute( 95 + client, 96 + "mutation InsertDirectory($playlistId: String, $position: Int!, $directory: String!) { insertDirectory(playlistId: $playlistId, position: $position, directory: $directory) }", 97 + vars 98 + ) 99 + ) 100 + end 101 + 102 + @doc "Insert every track from an album into the queue." 103 + @spec insert_album(Client.t(), String.t(), Types.insert_position()) :: 104 + :ok | {:error, Exception.t()} 105 + def insert_album(client, album_id, position \\ :last) do 106 + vars = %{album_id: album_id, position: Types.insert_position(position)} 107 + 108 + void( 109 + Transport.execute( 110 + client, 111 + "mutation InsertAlbum($albumId: String!, $position: Int!) { insertAlbum(albumId: $albumId, position: $position) }", 112 + vars 113 + ) 114 + ) 115 + end 116 + 117 + @doc "Remove the track at the given 0-based queue index." 118 + @spec remove_track(Client.t(), non_neg_integer()) :: :ok | {:error, Exception.t()} 119 + def remove_track(client, index) do 120 + void( 121 + Transport.execute( 122 + client, 123 + "mutation RemoveTrack($index: Int!) { playlistRemoveTrack(index: $index) }", 124 + %{index: index} 125 + ) 126 + ) 127 + end 128 + 129 + @doc "Empty the queue." 130 + @spec clear(Client.t()) :: :ok | {:error, Exception.t()} 131 + def clear(client), 132 + do: void(Transport.execute(client, "mutation ClearPlaylist { playlistRemoveAllTracks }")) 133 + 134 + @doc "Reshuffle the queue in place." 135 + @spec shuffle(Client.t()) :: :ok | {:error, Exception.t()} 136 + def shuffle(client), 137 + do: void(Transport.execute(client, "mutation ShufflePlaylist { shufflePlaylist }")) 138 + 139 + @doc "Create a new temporary queue (replaces the current one) and start playing." 140 + @spec create(Client.t(), String.t(), [String.t()]) :: :ok | {:error, Exception.t()} 141 + def create(client, name, tracks) do 142 + void( 143 + Transport.execute( 144 + client, 145 + "mutation CreatePlaylist($name: String!, $tracks: [String!]!) { playlistCreate(name: $name, tracks: $tracks) }", 146 + %{name: name, tracks: tracks} 147 + ) 148 + ) 149 + end 150 + 151 + @doc "Begin playback of the current queue. Options: `:start_index`, `:elapsed`, `:offset`." 152 + @spec start(Client.t(), keyword()) :: :ok | {:error, Exception.t()} 153 + def start(client, opts \\ []) do 154 + void( 155 + Transport.execute( 156 + client, 157 + "mutation PlaylistStart($startIndex: Int, $elapsed: Int, $offset: Int) { playlistStart(startIndex: $startIndex, elapsed: $elapsed, offset: $offset) }", 158 + Map.new(opts) 159 + ) 160 + ) 161 + end 162 + 163 + @doc "Resume the queue from where playback was last stopped." 164 + @spec resume(Client.t()) :: :ok | {:error, Exception.t()} 165 + def resume(client), 166 + do: void(Transport.execute(client, "mutation PlaylistResume { playlistResume }")) 167 + 168 + # --------------------------------------------------------------------------- 169 + # Internal 170 + # --------------------------------------------------------------------------- 171 + 172 + defp void({:ok, _}), do: :ok 173 + defp void(err), do: err 174 + 175 + defp bang({:ok, value}), do: value 176 + defp bang(:ok), do: :ok 177 + defp bang({:error, exception}), do: raise(exception) 178 + end
+11
sdk/elixir/lib/rockbox/replaygain.ex
··· 1 + defmodule Rockbox.Replaygain do 2 + @moduledoc "Replaygain settings." 3 + 4 + @type t :: %__MODULE__{ 5 + noclip: boolean(), 6 + type: integer(), 7 + preamp: integer() 8 + } 9 + 10 + defstruct [:noclip, :type, :preamp] 11 + end
+25
sdk/elixir/lib/rockbox/saved_playlist.ex
··· 1 + defmodule Rockbox.SavedPlaylist do 2 + @moduledoc "A persistent named playlist stored in the library database." 3 + 4 + @type t :: %__MODULE__{ 5 + id: String.t(), 6 + name: String.t(), 7 + description: String.t() | nil, 8 + image: String.t() | nil, 9 + folder_id: String.t() | nil, 10 + track_count: integer(), 11 + created_at: integer(), 12 + updated_at: integer() 13 + } 14 + 15 + defstruct [ 16 + :id, 17 + :name, 18 + :description, 19 + :image, 20 + :folder_id, 21 + :track_count, 22 + :created_at, 23 + :updated_at 24 + ] 25 + end
+12
sdk/elixir/lib/rockbox/saved_playlist_folder.ex
··· 1 + defmodule Rockbox.SavedPlaylistFolder do 2 + @moduledoc "A folder grouping saved playlists together." 3 + 4 + @type t :: %__MODULE__{ 5 + id: String.t(), 6 + name: String.t(), 7 + created_at: integer(), 8 + updated_at: integer() 9 + } 10 + 11 + defstruct [:id, :name, :created_at, :updated_at] 12 + end
+187
sdk/elixir/lib/rockbox/saved_playlists.ex
··· 1 + defmodule Rockbox.SavedPlaylists do 2 + @moduledoc """ 3 + Persistent named playlists, optionally grouped into folders. 4 + 5 + {:ok, pl} = Rockbox.SavedPlaylists.create(client, 6 + name: "Late Night Jazz", 7 + track_ids: ["t1", "t2", "t3"] 8 + ) 9 + """ 10 + 11 + alias Rockbox.{Client, SavedPlaylist, SavedPlaylistFolder, Transport, Util} 12 + 13 + @playlist_fields "id name description image folderId trackCount createdAt updatedAt" 14 + @folder_fields "id name createdAt updatedAt" 15 + 16 + # --------------------------------------------------------------------------- 17 + # Playlists 18 + # --------------------------------------------------------------------------- 19 + 20 + @doc "List saved playlists, optionally filtered by `folder_id`." 21 + @spec list(Client.t(), String.t() | nil) :: {:ok, [SavedPlaylist.t()]} | {:error, Exception.t()} 22 + def list(client, folder_id \\ nil) do 23 + query = 24 + "query SavedPlaylists($folderId: String) { savedPlaylists(folderId: $folderId) { #{@playlist_fields} } }" 25 + 26 + with {:ok, %{"savedPlaylists" => list}} <- 27 + Transport.execute(client, query, %{folder_id: folder_id}) do 28 + {:ok, Util.to_struct_list(SavedPlaylist, list)} 29 + end 30 + end 31 + 32 + @spec list!(Client.t(), String.t() | nil) :: [SavedPlaylist.t()] 33 + def list!(client, folder_id \\ nil), do: bang(list(client, folder_id)) 34 + 35 + @doc "Fetch a saved playlist by id." 36 + @spec get(Client.t(), String.t()) :: {:ok, SavedPlaylist.t() | nil} | {:error, Exception.t()} 37 + def get(client, id) do 38 + query = "query SavedPlaylist($id: String!) { savedPlaylist(id: $id) { #{@playlist_fields} } }" 39 + 40 + with {:ok, %{"savedPlaylist" => raw}} <- Transport.execute(client, query, %{id: id}) do 41 + {:ok, Util.to_struct(SavedPlaylist, raw)} 42 + end 43 + end 44 + 45 + @spec get!(Client.t(), String.t()) :: SavedPlaylist.t() | nil 46 + def get!(client, id), do: bang(get(client, id)) 47 + 48 + @doc "Get the ordered track ids of a saved playlist." 49 + @spec track_ids(Client.t(), String.t()) :: {:ok, [String.t()]} | {:error, Exception.t()} 50 + def track_ids(client, playlist_id) do 51 + query = 52 + "query SavedPlaylistTrackIds($playlistId: String!) { savedPlaylistTrackIds(playlistId: $playlistId) }" 53 + 54 + with {:ok, %{"savedPlaylistTrackIds" => ids}} <- 55 + Transport.execute(client, query, %{playlist_id: playlist_id}), 56 + do: {:ok, ids} 57 + end 58 + 59 + @doc """ 60 + Create a saved playlist. 61 + 62 + Required: `:name`. Optional: `:description`, `:image`, `:folder_id`, `:track_ids`. 63 + """ 64 + @spec create(Client.t(), keyword() | map()) :: 65 + {:ok, SavedPlaylist.t()} | {:error, Exception.t()} 66 + def create(client, attrs) do 67 + vars = attrs |> Map.new() |> Map.take([:name, :description, :image, :folder_id, :track_ids]) 68 + 69 + query = 70 + "mutation CreateSavedPlaylist($name: String!, $description: String, $image: String, $folderId: String, $trackIds: [String!]) { createSavedPlaylist(name: $name, description: $description, image: $image, folderId: $folderId, trackIds: $trackIds) { #{@playlist_fields} } }" 71 + 72 + with {:ok, %{"createSavedPlaylist" => raw}} <- Transport.execute(client, query, vars) do 73 + {:ok, Util.to_struct(SavedPlaylist, raw)} 74 + end 75 + end 76 + 77 + @spec create!(Client.t(), keyword() | map()) :: SavedPlaylist.t() 78 + def create!(client, attrs), do: bang(create(client, attrs)) 79 + 80 + @doc "Update a saved playlist's metadata. Pass any subset of `:name`, `:description`, `:image`, `:folder_id`." 81 + @spec update(Client.t(), String.t(), keyword() | map()) :: :ok | {:error, Exception.t()} 82 + def update(client, id, attrs) do 83 + vars = 84 + attrs 85 + |> Map.new() 86 + |> Map.take([:name, :description, :image, :folder_id]) 87 + |> Map.put(:id, id) 88 + 89 + void( 90 + Transport.execute( 91 + client, 92 + "mutation UpdateSavedPlaylist($id: String!, $name: String!, $description: String, $image: String, $folderId: String) { updateSavedPlaylist(id: $id, name: $name, description: $description, image: $image, folderId: $folderId) }", 93 + vars 94 + ) 95 + ) 96 + end 97 + 98 + @doc "Permanently delete a saved playlist." 99 + @spec delete(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 100 + def delete(client, id), 101 + do: 102 + void( 103 + Transport.execute( 104 + client, 105 + "mutation DeleteSavedPlaylist($id: String!) { deleteSavedPlaylist(id: $id) }", 106 + %{id: id} 107 + ) 108 + ) 109 + 110 + @spec add_tracks(Client.t(), String.t(), [String.t()]) :: :ok | {:error, Exception.t()} 111 + def add_tracks(client, playlist_id, track_ids) do 112 + void( 113 + Transport.execute( 114 + client, 115 + "mutation AddTracksToSavedPlaylist($playlistId: String!, $trackIds: [String!]!) { addTracksToSavedPlaylist(playlistId: $playlistId, trackIds: $trackIds) }", 116 + %{playlist_id: playlist_id, track_ids: track_ids} 117 + ) 118 + ) 119 + end 120 + 121 + @spec remove_track(Client.t(), String.t(), String.t()) :: :ok | {:error, Exception.t()} 122 + def remove_track(client, playlist_id, track_id) do 123 + void( 124 + Transport.execute( 125 + client, 126 + "mutation RemoveTrackFromSavedPlaylist($playlistId: String!, $trackId: String!) { removeTrackFromSavedPlaylist(playlistId: $playlistId, trackId: $trackId) }", 127 + %{playlist_id: playlist_id, track_id: track_id} 128 + ) 129 + ) 130 + end 131 + 132 + @doc "Load this playlist into the active queue and start playing." 133 + @spec play(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 134 + def play(client, playlist_id) do 135 + void( 136 + Transport.execute( 137 + client, 138 + "mutation PlaySavedPlaylist($playlistId: String!) { playSavedPlaylist(playlistId: $playlistId) }", 139 + %{playlist_id: playlist_id} 140 + ) 141 + ) 142 + end 143 + 144 + # --------------------------------------------------------------------------- 145 + # Folders 146 + # --------------------------------------------------------------------------- 147 + 148 + @spec folders(Client.t()) :: {:ok, [SavedPlaylistFolder.t()]} | {:error, Exception.t()} 149 + def folders(client) do 150 + query = "query PlaylistFolders { playlistFolders { #{@folder_fields} } }" 151 + 152 + with {:ok, %{"playlistFolders" => list}} <- Transport.execute(client, query) do 153 + {:ok, Util.to_struct_list(SavedPlaylistFolder, list)} 154 + end 155 + end 156 + 157 + @spec create_folder(Client.t(), String.t()) :: 158 + {:ok, SavedPlaylistFolder.t()} | {:error, Exception.t()} 159 + def create_folder(client, name) do 160 + with {:ok, %{"createPlaylistFolder" => raw}} <- 161 + Transport.execute( 162 + client, 163 + "mutation CreatePlaylistFolder($name: String!) { createPlaylistFolder(name: $name) { #{@folder_fields} } }", 164 + %{name: name} 165 + ) do 166 + {:ok, Util.to_struct(SavedPlaylistFolder, raw)} 167 + end 168 + end 169 + 170 + @spec delete_folder(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 171 + def delete_folder(client, id), 172 + do: 173 + void( 174 + Transport.execute( 175 + client, 176 + "mutation DeletePlaylistFolder($id: String!) { deletePlaylistFolder(id: $id) }", 177 + %{id: id} 178 + ) 179 + ) 180 + 181 + defp void({:ok, _}), do: :ok 182 + defp void(err), do: err 183 + 184 + defp bang({:ok, value}), do: value 185 + defp bang(:ok), do: :ok 186 + defp bang({:error, exception}), do: raise(exception) 187 + end
+13
sdk/elixir/lib/rockbox/search_results.ex
··· 1 + defmodule Rockbox.SearchResults do 2 + @moduledoc "Aggregated results returned by `Rockbox.Library.search/2`." 3 + 4 + @type t :: %__MODULE__{ 5 + artists: [Rockbox.Artist.t()], 6 + albums: [Rockbox.Album.t()], 7 + tracks: [Rockbox.Track.t()], 8 + liked_tracks: [Rockbox.Track.t()], 9 + liked_albums: [Rockbox.Album.t()] 10 + } 11 + 12 + defstruct artists: [], albums: [], tracks: [], liked_tracks: [], liked_albums: [] 13 + end
+82
sdk/elixir/lib/rockbox/settings.ex
··· 1 + defmodule Rockbox.Settings do 2 + @moduledoc """ 3 + Read and write global user settings. `update/2` accepts any subset of the 4 + fields in `Rockbox.UserSettings` — only the supplied keys are written. 5 + 6 + Rockbox.Settings.update(client, 7 + eq_enabled: true, 8 + eq_band_settings: [ 9 + %{cutoff: 60, q: 7, gain: 3}, 10 + %{cutoff: 200, q: 7, gain: 0}, 11 + %{cutoff: 4000, q: 7, gain: -2} 12 + ] 13 + ) 14 + """ 15 + 16 + alias Rockbox.{Client, Compressor, EqBand, Replaygain, Transport, UserSettings, Util} 17 + 18 + @fields ~S""" 19 + musicDir volume balance bass treble channelConfig stereoWidth 20 + eqEnabled eqPrecut 21 + eqBandSettings { cutoff q gain } 22 + replaygainSettings { noclip type preamp } 23 + compressorSettings { threshold makeupGain ratio knee releaseTime attackTime } 24 + crossfadeEnabled crossfadeFadeInDelay crossfadeFadeInDuration 25 + crossfadeFadeOutDelay crossfadeFadeOutDuration crossfadeFadeOutMixmode 26 + crossfeedEnabled crossfeedDirectGain crossfeedCrossGain 27 + crossfeedHfAttenuation crossfeedHfCutoff 28 + repeatMode singleMode partyMode shuffle playerName 29 + """ 30 + 31 + @doc "Read the entire global settings object." 32 + @spec get(Client.t()) :: {:ok, UserSettings.t()} | {:error, Exception.t()} 33 + def get(client) do 34 + query = "query GlobalSettings { globalSettings { #{@fields} } }" 35 + 36 + with {:ok, %{"globalSettings" => raw}} <- Transport.execute(client, query) do 37 + atomized = Util.atomize(raw) 38 + base = Util.to_struct(UserSettings, raw) 39 + 40 + {:ok, 41 + %{ 42 + base 43 + | eq_band_settings: 44 + Util.to_struct_list(EqBand, Map.get(atomized, :eq_band_settings, [])), 45 + replaygain_settings: 46 + Util.to_struct(Replaygain, Map.get(atomized, :replaygain_settings)), 47 + compressor_settings: 48 + Util.to_struct(Compressor, Map.get(atomized, :compressor_settings)) 49 + }} 50 + end 51 + end 52 + 53 + @spec get!(Client.t()) :: UserSettings.t() 54 + def get!(client), do: bang(get(client)) 55 + 56 + @doc """ 57 + Save a partial settings update. Pass a keyword list or map of snake_case 58 + fields — they are converted to the camelCase shape the server expects. 59 + """ 60 + @spec update(Client.t(), keyword() | map()) :: :ok | {:error, Exception.t()} 61 + def update(client, attrs) do 62 + settings = attrs |> Map.new() |> Util.camelize() 63 + 64 + void( 65 + Transport.execute( 66 + client, 67 + "mutation SaveSettings($settings: NewGlobalSettings!) { saveSettings(settings: $settings) }", 68 + %{"settings" => settings} 69 + ) 70 + ) 71 + end 72 + 73 + @spec update!(Client.t(), keyword() | map()) :: :ok 74 + def update!(client, attrs), do: bang(update(client, attrs)) 75 + 76 + defp void({:ok, _}), do: :ok 77 + defp void(err), do: err 78 + 79 + defp bang({:ok, value}), do: value 80 + defp bang(:ok), do: :ok 81 + defp bang({:error, exception}), do: raise(exception) 82 + end
+31
sdk/elixir/lib/rockbox/smart_playlist.ex
··· 1 + defmodule Rockbox.SmartPlaylist do 2 + @moduledoc """ 3 + A rule-based playlist that resolves to a fresh track set every time it is 4 + played. Rules are stored as JSON; build them with 5 + `Rockbox.SmartPlaylist.Rules`. 6 + """ 7 + 8 + @type t :: %__MODULE__{ 9 + id: String.t(), 10 + name: String.t(), 11 + description: String.t() | nil, 12 + image: String.t() | nil, 13 + folder_id: String.t() | nil, 14 + is_system: boolean(), 15 + rules: String.t(), 16 + created_at: integer(), 17 + updated_at: integer() 18 + } 19 + 20 + defstruct [ 21 + :id, 22 + :name, 23 + :description, 24 + :image, 25 + :folder_id, 26 + :is_system, 27 + :rules, 28 + :created_at, 29 + :updated_at 30 + ] 31 + end
+84
sdk/elixir/lib/rockbox/smart_playlist/rules.ex
··· 1 + defmodule Rockbox.SmartPlaylist.Rules do 2 + @moduledoc """ 3 + Composable builder for smart-playlist rule sets. Pipe-friendly, builder-friendly. 4 + 5 + alias Rockbox.SmartPlaylist.Rules 6 + 7 + rules = 8 + Rules.all_of() 9 + |> Rules.where(:play_count, :gte, 10) 10 + |> Rules.where(:last_played, :within, "30d") 11 + |> Rules.sort(:play_count, :desc) 12 + |> Rules.limit(50) 13 + |> Rules.to_json() 14 + 15 + The same builder works with `:any_of/0` for OR logic. The output is the 16 + JSON string the GraphQL `createSmartPlaylist` mutation expects. 17 + 18 + ## Common operators 19 + 20 + | atom | meaning | 21 + |-------------|--------------------------------------| 22 + | `:eq` | equals | 23 + | `:neq` | not equals | 24 + | `:gt` | greater than | 25 + | `:gte` | greater than or equal | 26 + | `:lt` | less than | 27 + | `:lte` | less than or equal | 28 + | `:contains` | substring match | 29 + | `:within` | duration window (e.g. `"30d"`, `"7d"`) | 30 + """ 31 + 32 + @type t :: %__MODULE__{ 33 + operator: String.t(), 34 + rules: list(), 35 + sort: map() | nil, 36 + limit: pos_integer() | nil 37 + } 38 + 39 + defstruct operator: "AND", rules: [], sort: nil, limit: nil 40 + 41 + @doc "Start a builder where every rule must match (AND)." 42 + @spec all_of() :: t() 43 + def all_of, do: %__MODULE__{operator: "AND"} 44 + 45 + @doc "Start a builder where any single rule must match (OR)." 46 + @spec any_of() :: t() 47 + def any_of, do: %__MODULE__{operator: "OR"} 48 + 49 + @doc "Append a single rule. `op` is an atom; `value` is any JSON-encodable term." 50 + @spec where(t(), atom(), atom(), term()) :: t() 51 + def where(%__MODULE__{rules: rules} = builder, field, op, value) do 52 + %{builder | rules: rules ++ [%{field: to_string(field), op: to_string(op), value: value}]} 53 + end 54 + 55 + @doc "Nest a sub-builder. Useful for mixing AND/OR groups." 56 + @spec where_group(t(), t()) :: t() 57 + def where_group(%__MODULE__{rules: rules} = parent, %__MODULE__{} = child) do 58 + %{parent | rules: rules ++ [to_map(child)]} 59 + end 60 + 61 + @doc "Set the result ordering. `dir` is `:asc` or `:desc`." 62 + @spec sort(t(), atom(), :asc | :desc) :: t() 63 + def sort(%__MODULE__{} = builder, field, dir) when dir in [:asc, :desc] do 64 + %{builder | sort: %{field: to_string(field), dir: to_string(dir)}} 65 + end 66 + 67 + @doc "Cap the result count." 68 + @spec limit(t(), pos_integer()) :: t() 69 + def limit(%__MODULE__{} = builder, n) when is_integer(n) and n > 0 do 70 + %{builder | limit: n} 71 + end 72 + 73 + @doc "Render the builder as the plain map shape the server expects." 74 + @spec to_map(t()) :: map() 75 + def to_map(%__MODULE__{} = b) do 76 + base = %{operator: b.operator, rules: b.rules} 77 + base = if b.sort, do: Map.put(base, :sort, b.sort), else: base 78 + if b.limit, do: Map.put(base, :limit, b.limit), else: base 79 + end 80 + 81 + @doc "Render the builder as a JSON string ready for the smart-playlist mutation." 82 + @spec to_json(t()) :: String.t() 83 + def to_json(%__MODULE__{} = b), do: b |> to_map() |> Jason.encode!() 84 + end
+165
sdk/elixir/lib/rockbox/smart_playlists.ex
··· 1 + defmodule Rockbox.SmartPlaylists do 2 + @moduledoc """ 3 + Rule-based playlists that resolve to a fresh track set every time they are 4 + played. 5 + 6 + Build the rule JSON manually with `Jason.encode!/1`, or use 7 + `Rockbox.SmartPlaylist.Rules` for a typed builder: 8 + 9 + rules = 10 + Rockbox.SmartPlaylist.Rules.all_of() 11 + |> Rockbox.SmartPlaylist.Rules.where(:play_count, :gte, 10) 12 + |> Rockbox.SmartPlaylist.Rules.sort(:play_count, :desc) 13 + |> Rockbox.SmartPlaylist.Rules.limit(50) 14 + |> Rockbox.SmartPlaylist.Rules.to_json() 15 + 16 + {:ok, sp} = Rockbox.SmartPlaylists.create(client, name: "Top 50", rules: rules) 17 + """ 18 + 19 + alias Rockbox.{Client, SmartPlaylist, TrackStats, Transport, Util} 20 + 21 + @playlist_fields "id name description image folderId isSystem rules createdAt updatedAt" 22 + @stats_fields "trackId playCount skipCount lastPlayed lastSkipped updatedAt" 23 + 24 + @spec list(Client.t()) :: {:ok, [SmartPlaylist.t()]} | {:error, Exception.t()} 25 + def list(client) do 26 + query = "query SmartPlaylists { smartPlaylists { #{@playlist_fields} } }" 27 + 28 + with {:ok, %{"smartPlaylists" => list}} <- Transport.execute(client, query) do 29 + {:ok, Util.to_struct_list(SmartPlaylist, list)} 30 + end 31 + end 32 + 33 + @spec list!(Client.t()) :: [SmartPlaylist.t()] 34 + def list!(client), do: bang(list(client)) 35 + 36 + @spec get(Client.t(), String.t()) :: {:ok, SmartPlaylist.t() | nil} | {:error, Exception.t()} 37 + def get(client, id) do 38 + query = "query SmartPlaylist($id: String!) { smartPlaylist(id: $id) { #{@playlist_fields} } }" 39 + 40 + with {:ok, %{"smartPlaylist" => raw}} <- Transport.execute(client, query, %{id: id}) do 41 + {:ok, Util.to_struct(SmartPlaylist, raw)} 42 + end 43 + end 44 + 45 + @doc "Resolve the rule set right now and return matching track ids." 46 + @spec track_ids(Client.t(), String.t()) :: {:ok, [String.t()]} | {:error, Exception.t()} 47 + def track_ids(client, id) do 48 + with {:ok, %{"smartPlaylistTrackIds" => ids}} <- 49 + Transport.execute( 50 + client, 51 + "query SmartPlaylistTrackIds($id: String!) { smartPlaylistTrackIds(id: $id) }", 52 + %{id: id} 53 + ), 54 + do: {:ok, ids} 55 + end 56 + 57 + @doc """ 58 + Create a smart playlist. 59 + 60 + Required: `:name`, `:rules` (JSON string). 61 + Optional: `:description`, `:image`, `:folder_id`. 62 + """ 63 + @spec create(Client.t(), keyword() | map()) :: 64 + {:ok, SmartPlaylist.t()} | {:error, Exception.t()} 65 + def create(client, attrs) do 66 + vars = 67 + attrs 68 + |> Map.new() 69 + |> Map.take([:name, :rules, :description, :image, :folder_id]) 70 + 71 + query = 72 + "mutation CreateSmartPlaylist($name: String!, $rules: String!, $description: String, $image: String, $folderId: String) { createSmartPlaylist(name: $name, rules: $rules, description: $description, image: $image, folderId: $folderId) { #{@playlist_fields} } }" 73 + 74 + with {:ok, %{"createSmartPlaylist" => raw}} <- Transport.execute(client, query, vars) do 75 + {:ok, Util.to_struct(SmartPlaylist, raw)} 76 + end 77 + end 78 + 79 + @spec create!(Client.t(), keyword() | map()) :: SmartPlaylist.t() 80 + def create!(client, attrs), do: bang(create(client, attrs)) 81 + 82 + @doc "Update a smart playlist. Required: `:name`, `:rules`." 83 + @spec update(Client.t(), String.t(), keyword() | map()) :: :ok | {:error, Exception.t()} 84 + def update(client, id, attrs) do 85 + vars = 86 + attrs 87 + |> Map.new() 88 + |> Map.take([:name, :rules, :description, :image, :folder_id]) 89 + |> Map.put(:id, id) 90 + 91 + void( 92 + Transport.execute( 93 + client, 94 + "mutation UpdateSmartPlaylist($id: String!, $name: String!, $rules: String!, $description: String, $image: String, $folderId: String) { updateSmartPlaylist(id: $id, name: $name, rules: $rules, description: $description, image: $image, folderId: $folderId) }", 95 + vars 96 + ) 97 + ) 98 + end 99 + 100 + @spec delete(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 101 + def delete(client, id), 102 + do: 103 + void( 104 + Transport.execute( 105 + client, 106 + "mutation DeleteSmartPlaylist($id: String!) { deleteSmartPlaylist(id: $id) }", 107 + %{id: id} 108 + ) 109 + ) 110 + 111 + @spec play(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 112 + def play(client, id), 113 + do: 114 + void( 115 + Transport.execute( 116 + client, 117 + "mutation PlaySmartPlaylist($id: String!) { playSmartPlaylist(id: $id) }", 118 + %{id: id} 119 + ) 120 + ) 121 + 122 + # --------------------------------------------------------------------------- 123 + # Listening stats 124 + # --------------------------------------------------------------------------- 125 + 126 + @spec track_stats(Client.t(), String.t()) :: 127 + {:ok, TrackStats.t() | nil} | {:error, Exception.t()} 128 + def track_stats(client, track_id) do 129 + query = 130 + "query TrackStats($trackId: String!) { trackStats(trackId: $trackId) { #{@stats_fields} } }" 131 + 132 + with {:ok, %{"trackStats" => raw}} <- Transport.execute(client, query, %{track_id: track_id}) do 133 + {:ok, Util.to_struct(TrackStats, raw)} 134 + end 135 + end 136 + 137 + @spec record_played(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 138 + def record_played(client, track_id), 139 + do: 140 + void( 141 + Transport.execute( 142 + client, 143 + "mutation RecordTrackPlayed($trackId: String!) { recordTrackPlayed(trackId: $trackId) }", 144 + %{track_id: track_id} 145 + ) 146 + ) 147 + 148 + @spec record_skipped(Client.t(), String.t()) :: :ok | {:error, Exception.t()} 149 + def record_skipped(client, track_id), 150 + do: 151 + void( 152 + Transport.execute( 153 + client, 154 + "mutation RecordTrackSkipped($trackId: String!) { recordTrackSkipped(trackId: $trackId) }", 155 + %{track_id: track_id} 156 + ) 157 + ) 158 + 159 + defp void({:ok, _}), do: :ok 160 + defp void(err), do: err 161 + 162 + defp bang({:ok, value}), do: value 163 + defp bang(:ok), do: :ok 164 + defp bang({:error, exception}), do: raise(exception) 165 + end
+334
sdk/elixir/lib/rockbox/socket.ex
··· 1 + defmodule Rockbox.Socket do 2 + @moduledoc """ 3 + GenServer that manages the WebSocket connection to rockboxd, speaks the 4 + `graphql-transport-ws` subprotocol, and forwards incoming events to 5 + subscribers via `Rockbox.Events.broadcast/2`. 6 + 7 + You normally don't interact with this module directly — call 8 + `Rockbox.connect/1` to start it and `Rockbox.disconnect/1` to stop it. 9 + 10 + Reconnects automatically with exponential backoff (capped at 30s). 11 + """ 12 + 13 + use GenServer 14 + require Logger 15 + 16 + alias Rockbox.{Client, Events, Playlist, Track, Types, Util} 17 + 18 + @reconnect_min 1_000 19 + @reconnect_max 30_000 20 + 21 + # Subscriptions we always activate once the WS is connected. 22 + @subscriptions %{ 23 + "track" => ~S""" 24 + subscription CurrentlyPlaying { 25 + currentlyPlayingSong { 26 + id title artist album albumArt albumId artistId path length elapsed 27 + } 28 + } 29 + """, 30 + "status" => ~S"subscription PlaybackStatus { playbackStatus { status } }", 31 + "playlist" => ~S""" 32 + subscription PlaylistChanged { 33 + playlistChanged { 34 + amount index maxPlaylistSize firstIndex lastInsertPos seed lastShuffledStart 35 + tracks { id title artist album path length albumArt } 36 + } 37 + } 38 + """ 39 + } 40 + 41 + defstruct [ 42 + :client, 43 + :conn, 44 + :websocket, 45 + :ref, 46 + :host, 47 + :port, 48 + :path, 49 + :scheme, 50 + reconnect_attempt: 0, 51 + buffer: [], 52 + state: :disconnected 53 + ] 54 + 55 + # --------------------------------------------------------------------------- 56 + # Public API 57 + # --------------------------------------------------------------------------- 58 + 59 + @doc "Start (or fetch) the socket for `client`. Returns `{:ok, pid}`." 60 + @spec start_link(Client.t()) :: {:ok, pid()} | {:error, term()} 61 + def start_link(%Client{} = client) do 62 + GenServer.start_link(__MODULE__, client, name: via(client)) 63 + end 64 + 65 + @doc "Look up the running socket pid for `client`, if any." 66 + @spec whereis(Client.t()) :: pid() | nil 67 + def whereis(%Client{ws_url: url}) do 68 + case Registry.lookup(Rockbox.Sockets, url) do 69 + [{pid, _}] -> pid 70 + _ -> nil 71 + end 72 + end 73 + 74 + @doc "Stop the socket for `client`, if it's running." 75 + @spec stop(Client.t()) :: :ok 76 + def stop(%Client{} = client) do 77 + case whereis(client) do 78 + nil -> :ok 79 + pid -> GenServer.stop(pid, :normal) 80 + end 81 + end 82 + 83 + defp via(%Client{ws_url: url}), do: {:via, Registry, {Rockbox.Sockets, url}} 84 + 85 + # --------------------------------------------------------------------------- 86 + # GenServer callbacks 87 + # --------------------------------------------------------------------------- 88 + 89 + @impl true 90 + def init(client) do 91 + %URI{scheme: scheme_str, host: host, port: port, path: path} = URI.parse(client.ws_url) 92 + 93 + scheme = 94 + case scheme_str do 95 + "wss" -> :https 96 + _ -> :http 97 + end 98 + 99 + state = %__MODULE__{ 100 + client: client, 101 + host: host, 102 + port: port || default_port(scheme), 103 + path: path || "/graphql", 104 + scheme: scheme 105 + } 106 + 107 + send(self(), :connect) 108 + {:ok, state} 109 + end 110 + 111 + @impl true 112 + def handle_info(:connect, %__MODULE__{} = state) do 113 + case open_websocket(state) do 114 + {:ok, new_state} -> 115 + Events.broadcast(:ws_open, nil) 116 + new_state = send_init(new_state) 117 + new_state = subscribe_all(new_state) 118 + {:noreply, %{new_state | state: :ready, reconnect_attempt: 0}} 119 + 120 + {:error, reason} -> 121 + Logger.warning("[Rockbox.Socket] connect failed: #{inspect(reason)}") 122 + 123 + Events.broadcast(:ws_error, %Rockbox.NetworkError{ 124 + message: "WebSocket connect failed", 125 + cause: reason 126 + }) 127 + 128 + schedule_reconnect(state) 129 + end 130 + end 131 + 132 + def handle_info(message, %__MODULE__{conn: conn} = state) when not is_nil(conn) do 133 + case Mint.WebSocket.stream(conn, message) do 134 + {:ok, conn, responses} -> 135 + state = %{state | conn: conn} 136 + Enum.reduce(responses, {:noreply, state}, &handle_response/2) 137 + 138 + {:error, conn, reason, _responses} -> 139 + Logger.warning("[Rockbox.Socket] stream error: #{inspect(reason)}") 140 + 141 + Events.broadcast(:ws_error, %Rockbox.NetworkError{ 142 + message: "WebSocket stream error", 143 + cause: reason 144 + }) 145 + 146 + Events.broadcast(:ws_close, nil) 147 + schedule_reconnect(%{state | conn: conn, websocket: nil, state: :disconnected}) 148 + 149 + :unknown -> 150 + {:noreply, state} 151 + end 152 + end 153 + 154 + def handle_info(_other, state), do: {:noreply, state} 155 + 156 + @impl true 157 + def terminate(_reason, %__MODULE__{conn: conn}) when not is_nil(conn) do 158 + Mint.HTTP.close(conn) 159 + Events.broadcast(:ws_close, nil) 160 + :ok 161 + end 162 + 163 + def terminate(_, _), do: :ok 164 + 165 + # --------------------------------------------------------------------------- 166 + # Internal — WebSocket plumbing 167 + # --------------------------------------------------------------------------- 168 + 169 + defp open_websocket(%__MODULE__{} = state) do 170 + with {:ok, conn} <- 171 + Mint.HTTP.connect(state.scheme, state.host, state.port, protocols: [:http1]), 172 + {:ok, conn, ref} <- 173 + Mint.WebSocket.upgrade(ws_scheme(state.scheme), conn, state.path, [ 174 + {"sec-websocket-protocol", "graphql-transport-ws"} 175 + ]) do 176 + {:ok, %{state | conn: conn, ref: ref, state: :upgrading}} 177 + end 178 + end 179 + 180 + defp ws_scheme(:http), do: :ws 181 + defp ws_scheme(:https), do: :wss 182 + 183 + defp default_port(:http), do: 80 184 + defp default_port(:https), do: 443 185 + 186 + defp send_init(state), do: send_text(state, %{type: "connection_init", payload: %{}}) 187 + 188 + defp subscribe_all(state) do 189 + Enum.reduce(@subscriptions, state, fn {id, query}, acc -> 190 + send_text(acc, %{id: id, type: "subscribe", payload: %{query: query}}) 191 + end) 192 + end 193 + 194 + defp send_text(%__MODULE__{websocket: nil} = state, _msg), do: state 195 + 196 + defp send_text(%__MODULE__{conn: conn, websocket: ws, ref: ref} = state, msg) do 197 + json = Jason.encode!(msg) 198 + 199 + case Mint.WebSocket.encode(ws, {:text, json}) do 200 + {:ok, ws, data} -> 201 + case Mint.WebSocket.stream_request_body(conn, ref, data) do 202 + {:ok, conn} -> 203 + %{state | conn: conn, websocket: ws} 204 + 205 + {:error, conn, reason} -> 206 + Logger.warning("[Rockbox.Socket] send error: #{inspect(reason)}") 207 + %{state | conn: conn, websocket: ws} 208 + end 209 + 210 + {:error, ws, reason} -> 211 + Logger.warning("[Rockbox.Socket] encode error: #{inspect(reason)}") 212 + %{state | websocket: ws} 213 + end 214 + end 215 + 216 + defp handle_response({:status, ref, status}, {:noreply, %{ref: ref} = state}), 217 + do: {:noreply, %{state | buffer: [{:status, status} | state.buffer]}} 218 + 219 + defp handle_response({:headers, ref, headers}, {:noreply, %{ref: ref, conn: conn} = state}) do 220 + [{:status, status} | _] = state.buffer 221 + 222 + case Mint.WebSocket.new(conn, ref, status, headers) do 223 + {:ok, conn, ws} -> 224 + state = %{state | conn: conn, websocket: ws, buffer: [], state: :connected} 225 + state = send_init(state) 226 + state = subscribe_all(state) 227 + {:noreply, state} 228 + 229 + {:error, conn, reason} -> 230 + Logger.warning("[Rockbox.Socket] WS upgrade failed: #{inspect(reason)}") 231 + 232 + Events.broadcast(:ws_error, %Rockbox.NetworkError{ 233 + message: "WebSocket upgrade failed", 234 + cause: reason 235 + }) 236 + 237 + Mint.HTTP.close(conn) 238 + {:noreply, %{state | conn: nil, websocket: nil, state: :disconnected}} 239 + end 240 + end 241 + 242 + defp handle_response({:data, ref, data}, {:noreply, %{ref: ref, websocket: ws} = state}) 243 + when not is_nil(ws) do 244 + case Mint.WebSocket.decode(ws, data) do 245 + {:ok, ws, frames} -> 246 + state = %{state | websocket: ws} 247 + state = Enum.reduce(frames, state, &handle_frame/2) 248 + {:noreply, state} 249 + 250 + {:error, ws, reason} -> 251 + Logger.warning("[Rockbox.Socket] decode error: #{inspect(reason)}") 252 + {:noreply, %{state | websocket: ws}} 253 + end 254 + end 255 + 256 + defp handle_response({:done, _ref}, acc), do: acc 257 + defp handle_response(_other, acc), do: acc 258 + 259 + defp handle_frame({:text, text}, state) do 260 + case Jason.decode(text) do 261 + {:ok, msg} -> dispatch_message(msg, state) 262 + _ -> state 263 + end 264 + end 265 + 266 + defp handle_frame({:ping, payload}, state), do: send_pong(state, payload) 267 + defp handle_frame({:close, _, _}, state), do: state 268 + defp handle_frame(_, state), do: state 269 + 270 + defp send_pong(%__MODULE__{conn: conn, websocket: ws, ref: ref} = state, payload) do 271 + case Mint.WebSocket.encode(ws, {:pong, payload || ""}) do 272 + {:ok, ws, data} -> 273 + case Mint.WebSocket.stream_request_body(conn, ref, data) do 274 + {:ok, conn} -> %{state | conn: conn, websocket: ws} 275 + _ -> %{state | websocket: ws} 276 + end 277 + 278 + _ -> 279 + state 280 + end 281 + end 282 + 283 + # graphql-ws protocol messages 284 + defp dispatch_message(%{"type" => "connection_ack"}, state), do: state 285 + 286 + defp dispatch_message(%{"type" => "ping"}, state), 287 + do: send_text(state, %{type: "pong"}) 288 + 289 + defp dispatch_message(%{"type" => "pong"}, state), do: state 290 + 291 + defp dispatch_message(%{"type" => "next", "id" => id, "payload" => payload}, state) do 292 + handle_subscription_payload(id, payload) 293 + state 294 + end 295 + 296 + defp dispatch_message(%{"type" => "error", "id" => id, "payload" => payload}, state) do 297 + Logger.warning("[Rockbox.Socket] subscription #{id} error: #{inspect(payload)}") 298 + state 299 + end 300 + 301 + defp dispatch_message(%{"type" => "complete"}, state), do: state 302 + defp dispatch_message(_other, state), do: state 303 + 304 + defp handle_subscription_payload("track", %{"data" => %{"currentlyPlayingSong" => raw}}) 305 + when not is_nil(raw), 306 + do: Events.broadcast(:track_changed, Util.to_struct(Track, raw)) 307 + 308 + defp handle_subscription_payload("status", %{ 309 + "data" => %{"playbackStatus" => %{"status" => raw}} 310 + }), 311 + do: Events.broadcast(:status_changed, Types.playback_status(raw)) 312 + 313 + defp handle_subscription_payload("playlist", %{"data" => %{"playlistChanged" => raw}}) 314 + when not is_nil(raw) do 315 + atomized = Util.atomize(raw) 316 + base = Util.to_struct(Playlist, raw) 317 + pl = %{base | tracks: Util.to_struct_list(Track, Map.get(atomized, :tracks, []))} 318 + Events.broadcast(:playlist_changed, pl) 319 + end 320 + 321 + defp handle_subscription_payload(_id, _payload), do: :ok 322 + 323 + # --------------------------------------------------------------------------- 324 + # Reconnect 325 + # --------------------------------------------------------------------------- 326 + 327 + defp schedule_reconnect(%__MODULE__{reconnect_attempt: n} = state) do 328 + delay = min(@reconnect_min * round(:math.pow(2, n)), @reconnect_max) 329 + Process.send_after(self(), :connect, delay) 330 + 331 + {:noreply, 332 + %{state | reconnect_attempt: n + 1, conn: nil, websocket: nil, state: :reconnecting}} 333 + end 334 + end
+42
sdk/elixir/lib/rockbox/sound.ex
··· 1 + defmodule Rockbox.Sound do 2 + @moduledoc """ 3 + Volume controls. Volume is adjusted in firmware-defined steps — the absolute 4 + range varies by hardware target, so always check `volume/1` for `:min`/`:max`. 5 + """ 6 + 7 + alias Rockbox.{Client, Transport, Util, Volume} 8 + 9 + @doc "Get the current volume with its hardware min/max range." 10 + @spec volume(Client.t()) :: {:ok, Volume.t()} | {:error, Exception.t()} 11 + def volume(client) do 12 + case Transport.execute(client, "query Volume { volume { volume min max } }") do 13 + {:ok, %{"volume" => raw}} -> {:ok, Util.to_struct(Volume, raw)} 14 + err -> err 15 + end 16 + end 17 + 18 + @spec volume!(Client.t()) :: Volume.t() 19 + def volume!(client), do: bang(volume(client)) 20 + 21 + @doc "Adjust volume by a relative number of steps (positive = louder)." 22 + @spec adjust(Client.t(), integer()) :: {:ok, integer()} | {:error, Exception.t()} 23 + def adjust(client, steps) when is_integer(steps) do 24 + case Transport.execute( 25 + client, 26 + "mutation AdjustVolume($steps: Int!) { adjustVolume(steps: $steps) }", 27 + %{steps: steps} 28 + ) do 29 + {:ok, %{"adjustVolume" => raw}} -> {:ok, raw} 30 + err -> err 31 + end 32 + end 33 + 34 + @spec up(Client.t()) :: {:ok, integer()} | {:error, Exception.t()} 35 + def up(client), do: adjust(client, 1) 36 + 37 + @spec down(Client.t()) :: {:ok, integer()} | {:error, Exception.t()} 38 + def down(client), do: adjust(client, -1) 39 + 40 + defp bang({:ok, value}), do: value 41 + defp bang({:error, exception}), do: raise(exception) 42 + end
+41
sdk/elixir/lib/rockbox/system.ex
··· 1 + defmodule Rockbox.System do 2 + @moduledoc "System-level information about the rockboxd instance." 3 + 4 + alias Rockbox.{Client, SystemStatus, Transport, Util} 5 + 6 + @doc "rockboxd version string." 7 + @spec version(Client.t()) :: {:ok, String.t()} | {:error, Exception.t()} 8 + def version(client) do 9 + case Transport.execute(client, "query Version { rockboxVersion }") do 10 + {:ok, %{"rockboxVersion" => v}} -> {:ok, v} 11 + err -> err 12 + end 13 + end 14 + 15 + @spec version!(Client.t()) :: String.t() 16 + def version!(client), do: bang(version(client)) 17 + 18 + @doc "Aggregate runtime status." 19 + @spec status(Client.t()) :: {:ok, SystemStatus.t()} | {:error, Exception.t()} 20 + def status(client) do 21 + query = ~S""" 22 + query GlobalStatus { 23 + globalStatus { 24 + resumeIndex resumeCrc32 resumeElapsed resumeOffset 25 + runtime topruntime dircacheSize 26 + lastScreen viewerIconCount lastVolumeChange 27 + } 28 + } 29 + """ 30 + 31 + with {:ok, %{"globalStatus" => raw}} <- Transport.execute(client, query) do 32 + {:ok, Util.to_struct(SystemStatus, raw)} 33 + end 34 + end 35 + 36 + @spec status!(Client.t()) :: SystemStatus.t() 37 + def status!(client), do: bang(status(client)) 38 + 39 + defp bang({:ok, value}), do: value 40 + defp bang({:error, exception}), do: raise(exception) 41 + end
+29
sdk/elixir/lib/rockbox/system_status.ex
··· 1 + defmodule Rockbox.SystemStatus do 2 + @moduledoc "Aggregate runtime information about the rockboxd instance." 3 + 4 + @type t :: %__MODULE__{ 5 + resume_index: integer(), 6 + resume_crc32: integer(), 7 + resume_elapsed: integer(), 8 + resume_offset: integer(), 9 + runtime: integer(), 10 + topruntime: integer(), 11 + dircache_size: integer(), 12 + last_screen: integer(), 13 + viewer_icon_count: integer(), 14 + last_volume_change: integer() 15 + } 16 + 17 + defstruct [ 18 + :resume_index, 19 + :resume_crc32, 20 + :resume_elapsed, 21 + :resume_offset, 22 + :runtime, 23 + :topruntime, 24 + :dircache_size, 25 + :last_screen, 26 + :viewer_icon_count, 27 + :last_volume_change 28 + ] 29 + end
+78
sdk/elixir/lib/rockbox/track.ex
··· 1 + defmodule Rockbox.Track do 2 + @moduledoc "A single audio track. `:length` and `:elapsed` are milliseconds." 3 + 4 + @type t :: %__MODULE__{ 5 + id: String.t() | nil, 6 + title: String.t(), 7 + artist: String.t(), 8 + album: String.t(), 9 + genre: String.t(), 10 + disc: String.t(), 11 + track_string: String.t(), 12 + year_string: String.t(), 13 + composer: String.t(), 14 + comment: String.t(), 15 + album_artist: String.t(), 16 + grouping: String.t(), 17 + discnum: integer(), 18 + tracknum: integer(), 19 + layer: integer(), 20 + year: integer(), 21 + bitrate: integer(), 22 + frequency: integer(), 23 + filesize: integer(), 24 + length: integer(), 25 + elapsed: integer(), 26 + path: String.t(), 27 + album_id: String.t() | nil, 28 + artist_id: String.t() | nil, 29 + genre_id: String.t() | nil, 30 + album_art: String.t() | nil 31 + } 32 + 33 + defstruct [ 34 + :id, 35 + :title, 36 + :artist, 37 + :album, 38 + :genre, 39 + :disc, 40 + :track_string, 41 + :year_string, 42 + :composer, 43 + :comment, 44 + :album_artist, 45 + :grouping, 46 + :discnum, 47 + :tracknum, 48 + :layer, 49 + :year, 50 + :bitrate, 51 + :frequency, 52 + :filesize, 53 + :length, 54 + :elapsed, 55 + :path, 56 + :album_id, 57 + :artist_id, 58 + :genre_id, 59 + :album_art 60 + ] 61 + 62 + @doc "Format the track length as `M:SS`." 63 + @spec format_length(t()) :: String.t() 64 + def format_length(%__MODULE__{length: ms}) when is_integer(ms), do: Rockbox.format_ms(ms) 65 + def format_length(_), do: "0:00" 66 + 67 + @doc "Format the elapsed position as `M:SS`." 68 + @spec format_elapsed(t()) :: String.t() 69 + def format_elapsed(%__MODULE__{elapsed: ms}) when is_integer(ms), do: Rockbox.format_ms(ms) 70 + def format_elapsed(_), do: "0:00" 71 + 72 + @doc "Progress through the track as a 0.0–1.0 float." 73 + @spec progress(t()) :: float() 74 + def progress(%__MODULE__{length: len, elapsed: elapsed}) when is_integer(len) and len > 0, 75 + do: elapsed / len 76 + 77 + def progress(_), do: 0.0 78 + end
+14
sdk/elixir/lib/rockbox/track_stats.ex
··· 1 + defmodule Rockbox.TrackStats do 2 + @moduledoc "Listening statistics for a track — feeds smart playlist rules." 3 + 4 + @type t :: %__MODULE__{ 5 + track_id: String.t(), 6 + play_count: integer(), 7 + skip_count: integer(), 8 + last_played: integer() | nil, 9 + last_skipped: integer() | nil, 10 + updated_at: integer() 11 + } 12 + 13 + defstruct [:track_id, :play_count, :skip_count, :last_played, :last_skipped, :updated_at] 14 + end
+84
sdk/elixir/lib/rockbox/transport.ex
··· 1 + defmodule Rockbox.Transport do 2 + @moduledoc """ 3 + Low-level GraphQL HTTP transport. You normally don't call this directly — 4 + the `Rockbox.*` API modules wrap it. Exposed because `Rockbox.query/3` and 5 + the plugin system need a stable hook for raw queries. 6 + """ 7 + 8 + alias Rockbox.{Client, Error, GraphQLError, NetworkError, Util} 9 + 10 + @type variables :: map() | Keyword.t() | nil 11 + 12 + @doc """ 13 + Execute a GraphQL operation and return `{:ok, data}` or `{:error, exception}`. 14 + 15 + `data` is returned as-is (a map), not coerced to a struct — the API modules 16 + handle struct conversion. Variables are passed through `Rockbox.Util.camelize/1` 17 + to convert snake_case keys to the camelCase the server expects. 18 + """ 19 + @spec execute(Client.t(), String.t(), variables()) :: 20 + {:ok, map()} | {:error, Exception.t()} 21 + def execute(%Client{} = client, query, variables \\ nil) do 22 + body = %{query: query, variables: encode_variables(variables)} 23 + 24 + case do_post(client, body) do 25 + {:ok, %{status: status, body: body}} when status in 200..299 -> 26 + case body do 27 + %{"errors" => [_ | _] = errors} -> 28 + {:error, GraphQLError.exception(Util.atomize(errors))} 29 + 30 + %{"data" => data} -> 31 + {:ok, data} 32 + 33 + other -> 34 + {:error, %Error{message: "Unexpected GraphQL response shape: #{inspect(other)}"}} 35 + end 36 + 37 + {:ok, %{status: status, body: body}} -> 38 + {:error, 39 + %NetworkError{ 40 + message: "HTTP #{status} from #{client.http_url}", 41 + cause: body 42 + }} 43 + 44 + {:error, reason} -> 45 + {:error, 46 + %NetworkError{ 47 + message: "Failed to reach rockboxd at #{client.http_url}", 48 + cause: reason 49 + }} 50 + end 51 + end 52 + 53 + @doc "Same as `execute/3` but raises on error." 54 + @spec execute!(Client.t(), String.t(), variables()) :: map() 55 + def execute!(client, query, variables \\ nil) do 56 + case execute(client, query, variables) do 57 + {:ok, data} -> data 58 + {:error, exception} -> raise exception 59 + end 60 + end 61 + 62 + # --------------------------------------------------------------------------- 63 + # Internal 64 + # --------------------------------------------------------------------------- 65 + 66 + defp do_post(%Client{} = client, body) do 67 + headers = [ 68 + {"content-type", "application/json"}, 69 + {"accept", "application/json"} | client.headers 70 + ] 71 + 72 + Req.post( 73 + client.http_url, 74 + json: body, 75 + headers: headers, 76 + receive_timeout: client.timeout, 77 + retry: false 78 + ) 79 + end 80 + 81 + defp encode_variables(nil), do: nil 82 + defp encode_variables(vars) when map_size(vars) == 0, do: nil 83 + defp encode_variables(vars), do: Util.camelize(vars) 84 + end
+146
sdk/elixir/lib/rockbox/types.ex
··· 1 + defmodule Rockbox.Types do 2 + @moduledoc """ 3 + Enums and shared constants. Use the atom forms (`:playing`, `:stopped`, …) 4 + in your code; the SDK converts to the integer the server expects on the way 5 + in and back to atoms on the way out. 6 + 7 + iex> Rockbox.Types.playback_status(1) 8 + :playing 9 + 10 + iex> Rockbox.Types.from_playback_status(:paused) 11 + 3 12 + 13 + ## Playback status 14 + 15 + | atom | int | 16 + |----------------|-----| 17 + | `:stopped` | 0 | 18 + | `:playing` | 1 | 19 + | `:paused` | 3 | 20 + 21 + ## Repeat mode 22 + 23 + | atom | int | 24 + |-------------|-----| 25 + | `:off` | 0 | 26 + | `:all` | 1 | 27 + | `:one` | 2 | 28 + | `:shuffle` | 3 | 29 + | `:ab_repeat`| 4 | 30 + 31 + ## Channel config 32 + 33 + | atom | int | 34 + |-----------------|-----| 35 + | `:stereo` | 0 | 36 + | `:stereo_narrow`| 1 | 37 + | `:mono` | 2 | 38 + | `:left_mix` | 3 | 39 + | `:right_mix` | 4 | 40 + | `:karaoke` | 5 | 41 + 42 + ## Replaygain type 43 + 44 + | atom | int | 45 + |------------|-----| 46 + | `:track` | 0 | 47 + | `:album` | 1 | 48 + | `:shuffle` | 2 | 49 + 50 + ## Insert position (queue) 51 + 52 + | atom | int | Effect | 53 + |-----------------|-----|------------------------------------------| 54 + | `:next` | 0 | After the currently playing track | 55 + | `:after_current`| 1 | After the last manually inserted track | 56 + | `:last` | 2 | At the end of the queue | 57 + | `:first` | 3 | Replace the entire queue | 58 + """ 59 + 60 + @type playback_status :: :stopped | :playing | :paused 61 + @type repeat_mode :: :off | :all | :one | :shuffle | :ab_repeat 62 + @type channel_config :: :stereo | :stereo_narrow | :mono | :left_mix | :right_mix | :karaoke 63 + @type replaygain_type :: :track | :album | :shuffle 64 + @type insert_position :: :next | :after_current | :last | :first 65 + 66 + # --------------------------------------------------------------------------- 67 + # Playback status 68 + # --------------------------------------------------------------------------- 69 + 70 + @spec playback_status(integer()) :: playback_status() 71 + def playback_status(0), do: :stopped 72 + def playback_status(1), do: :playing 73 + def playback_status(3), do: :paused 74 + def playback_status(other), do: {:unknown, other} 75 + 76 + @spec from_playback_status(playback_status()) :: integer() 77 + def from_playback_status(:stopped), do: 0 78 + def from_playback_status(:playing), do: 1 79 + def from_playback_status(:paused), do: 3 80 + 81 + # --------------------------------------------------------------------------- 82 + # Repeat mode 83 + # --------------------------------------------------------------------------- 84 + 85 + @spec repeat_mode(integer()) :: repeat_mode() 86 + def repeat_mode(0), do: :off 87 + def repeat_mode(1), do: :all 88 + def repeat_mode(2), do: :one 89 + def repeat_mode(3), do: :shuffle 90 + def repeat_mode(4), do: :ab_repeat 91 + def repeat_mode(other), do: {:unknown, other} 92 + 93 + @spec from_repeat_mode(repeat_mode()) :: integer() 94 + def from_repeat_mode(:off), do: 0 95 + def from_repeat_mode(:all), do: 1 96 + def from_repeat_mode(:one), do: 2 97 + def from_repeat_mode(:shuffle), do: 3 98 + def from_repeat_mode(:ab_repeat), do: 4 99 + 100 + # --------------------------------------------------------------------------- 101 + # Channel config 102 + # --------------------------------------------------------------------------- 103 + 104 + @spec channel_config(integer()) :: channel_config() 105 + def channel_config(0), do: :stereo 106 + def channel_config(1), do: :stereo_narrow 107 + def channel_config(2), do: :mono 108 + def channel_config(3), do: :left_mix 109 + def channel_config(4), do: :right_mix 110 + def channel_config(5), do: :karaoke 111 + def channel_config(other), do: {:unknown, other} 112 + 113 + @spec from_channel_config(channel_config()) :: integer() 114 + def from_channel_config(:stereo), do: 0 115 + def from_channel_config(:stereo_narrow), do: 1 116 + def from_channel_config(:mono), do: 2 117 + def from_channel_config(:left_mix), do: 3 118 + def from_channel_config(:right_mix), do: 4 119 + def from_channel_config(:karaoke), do: 5 120 + 121 + # --------------------------------------------------------------------------- 122 + # Replaygain 123 + # --------------------------------------------------------------------------- 124 + 125 + @spec replaygain_type(integer()) :: replaygain_type() 126 + def replaygain_type(0), do: :track 127 + def replaygain_type(1), do: :album 128 + def replaygain_type(2), do: :shuffle 129 + def replaygain_type(other), do: {:unknown, other} 130 + 131 + @spec from_replaygain_type(replaygain_type()) :: integer() 132 + def from_replaygain_type(:track), do: 0 133 + def from_replaygain_type(:album), do: 1 134 + def from_replaygain_type(:shuffle), do: 2 135 + 136 + # --------------------------------------------------------------------------- 137 + # Insert position 138 + # --------------------------------------------------------------------------- 139 + 140 + @spec insert_position(insert_position() | integer()) :: integer() 141 + def insert_position(:next), do: 0 142 + def insert_position(:after_current), do: 1 143 + def insert_position(:last), do: 2 144 + def insert_position(:first), do: 3 145 + def insert_position(int) when is_integer(int), do: int 146 + end
+68
sdk/elixir/lib/rockbox/user_settings.ex
··· 1 + defmodule Rockbox.UserSettings do 2 + @moduledoc """ 3 + Global user settings. Returned by `Rockbox.Settings.get/1` and accepted by 4 + `Rockbox.Settings.update/2` (any subset of fields can be supplied). 5 + """ 6 + 7 + @type t :: %__MODULE__{ 8 + music_dir: String.t(), 9 + volume: integer(), 10 + balance: integer(), 11 + bass: integer(), 12 + treble: integer(), 13 + channel_config: integer(), 14 + stereo_width: integer(), 15 + eq_enabled: boolean(), 16 + eq_precut: integer(), 17 + eq_band_settings: [Rockbox.EqBand.t()], 18 + replaygain_settings: Rockbox.Replaygain.t() | nil, 19 + compressor_settings: Rockbox.Compressor.t() | nil, 20 + crossfade_enabled: integer(), 21 + crossfade_fade_in_delay: integer(), 22 + crossfade_fade_in_duration: integer(), 23 + crossfade_fade_out_delay: integer(), 24 + crossfade_fade_out_duration: integer(), 25 + crossfade_fade_out_mixmode: integer(), 26 + crossfeed_enabled: boolean(), 27 + crossfeed_direct_gain: integer(), 28 + crossfeed_cross_gain: integer(), 29 + crossfeed_hf_attenuation: integer(), 30 + crossfeed_hf_cutoff: integer(), 31 + repeat_mode: integer(), 32 + single_mode: boolean(), 33 + party_mode: boolean(), 34 + shuffle: boolean(), 35 + player_name: String.t() 36 + } 37 + 38 + defstruct [ 39 + :music_dir, 40 + :volume, 41 + :balance, 42 + :bass, 43 + :treble, 44 + :channel_config, 45 + :stereo_width, 46 + :eq_enabled, 47 + :eq_precut, 48 + :replaygain_settings, 49 + :compressor_settings, 50 + :crossfade_enabled, 51 + :crossfade_fade_in_delay, 52 + :crossfade_fade_in_duration, 53 + :crossfade_fade_out_delay, 54 + :crossfade_fade_out_duration, 55 + :crossfade_fade_out_mixmode, 56 + :crossfeed_enabled, 57 + :crossfeed_direct_gain, 58 + :crossfeed_cross_gain, 59 + :crossfeed_hf_attenuation, 60 + :crossfeed_hf_cutoff, 61 + :repeat_mode, 62 + :single_mode, 63 + :party_mode, 64 + :shuffle, 65 + :player_name, 66 + eq_band_settings: [] 67 + ] 68 + end
+68
sdk/elixir/lib/rockbox/util.ex
··· 1 + defmodule Rockbox.Util do 2 + @moduledoc false 3 + 4 + # Internal helpers — JSON key conversion between the GraphQL camelCase wire 5 + # format and idiomatic snake_case Elixir keys, plus typed enum coercion. 6 + 7 + @doc "Recursively convert string camelCase map keys to snake_case atoms." 8 + @spec atomize(term()) :: term() 9 + def atomize(value) when is_list(value), do: Enum.map(value, &atomize/1) 10 + 11 + def atomize(%{} = map) do 12 + Map.new(map, fn {k, v} -> {to_snake_atom(k), atomize(v)} end) 13 + end 14 + 15 + def atomize(other), do: other 16 + 17 + @doc "Recursively convert snake_case atom map keys to camelCase strings." 18 + @spec camelize(term()) :: term() 19 + def camelize(value) when is_list(value), do: Enum.map(value, &camelize/1) 20 + 21 + def camelize(%{} = map) do 22 + Map.new(map, fn {k, v} -> {to_camel_string(k), camelize(v)} end) 23 + end 24 + 25 + def camelize(other), do: other 26 + 27 + @doc "Build a struct from a (possibly camelCase) map, ignoring unknown keys." 28 + @spec to_struct(module(), map() | nil) :: struct() | nil 29 + def to_struct(_module, nil), do: nil 30 + 31 + def to_struct(module, %{} = map) do 32 + atomized = atomize(map) 33 + blank = struct(module) 34 + fields = blank |> Map.from_struct() |> Map.keys() 35 + relevant = Map.take(atomized, fields) 36 + struct(module, relevant) 37 + end 38 + 39 + @doc "Build a list of structs from a list of maps." 40 + @spec to_struct_list(module(), [map()] | nil) :: [struct()] 41 + def to_struct_list(_module, nil), do: [] 42 + def to_struct_list(module, list), do: Enum.map(list, &to_struct(module, &1)) 43 + 44 + @doc "Convert a keyword list of options into a camelCase variables map for GraphQL." 45 + @spec opts_to_variables(Keyword.t() | map()) :: map() 46 + def opts_to_variables(opts) when is_list(opts), do: opts |> Map.new() |> camelize() 47 + def opts_to_variables(opts) when is_map(opts), do: camelize(opts) 48 + 49 + # --------------------------------------------------------------------------- 50 + # Internal 51 + # --------------------------------------------------------------------------- 52 + 53 + defp to_snake_atom(key) when is_atom(key), do: key 54 + 55 + defp to_snake_atom(key) when is_binary(key) do 56 + key 57 + |> String.replace(~r/([a-z0-9])([A-Z])/, "\\1_\\2") 58 + |> String.downcase() 59 + |> String.to_atom() 60 + end 61 + 62 + defp to_camel_string(key) when is_binary(key), do: to_camel_string(String.to_atom(key)) 63 + 64 + defp to_camel_string(key) when is_atom(key) do 65 + [head | rest] = key |> Atom.to_string() |> String.split("_") 66 + head <> Enum.map_join(rest, "", &String.capitalize/1) 67 + end 68 + end
+11
sdk/elixir/lib/rockbox/volume.ex
··· 1 + defmodule Rockbox.Volume do 2 + @moduledoc "Volume info: current value with the firmware-defined min/max range." 3 + 4 + @type t :: %__MODULE__{ 5 + volume: integer(), 6 + min: integer(), 7 + max: integer() 8 + } 9 + 10 + defstruct [:volume, :min, :max] 11 + end
+76
sdk/elixir/mix.exs
··· 1 + defmodule Rockbox.MixProject do 2 + use Mix.Project 3 + 4 + @version "0.1.0" 5 + @source_url "https://github.com/tsirysndr/rockbox-zig" 6 + 7 + def project do 8 + [ 9 + app: :rockbox_ex, 10 + version: @version, 11 + elixir: "~> 1.15", 12 + start_permanent: Mix.env() == :prod, 13 + deps: deps(), 14 + description: description(), 15 + package: package(), 16 + docs: docs(), 17 + name: "Rockbox", 18 + source_url: @source_url 19 + ] 20 + end 21 + 22 + def application do 23 + [ 24 + extra_applications: [:logger], 25 + mod: {Rockbox.Application, []} 26 + ] 27 + end 28 + 29 + defp deps do 30 + [ 31 + {:req, "~> 0.5"}, 32 + {:jason, "~> 1.4"}, 33 + {:mint_web_socket, "~> 1.0"}, 34 + {:ex_doc, "~> 0.34", only: :dev, runtime: false} 35 + ] 36 + end 37 + 38 + defp description do 39 + "Idiomatic Elixir SDK for Rockbox — pipe-friendly, builder-friendly, with real-time event subscriptions and a plugin system." 40 + end 41 + 42 + defp package do 43 + [ 44 + maintainers: ["Tsiry Sandratraina"], 45 + licenses: ["MIT"], 46 + links: %{"GitHub" => @source_url, "Rockbox" => "https://www.rockbox.org"}, 47 + files: ~w(lib mix.exs README.md .formatter.exs) 48 + ] 49 + end 50 + 51 + defp docs do 52 + [ 53 + main: "Rockbox", 54 + extras: ["README.md"], 55 + groups_for_modules: [ 56 + Core: [Rockbox, Rockbox.Client, Rockbox.Error], 57 + APIs: [ 58 + Rockbox.Playback, 59 + Rockbox.Library, 60 + Rockbox.Queue, 61 + Rockbox.SavedPlaylists, 62 + Rockbox.SmartPlaylists, 63 + Rockbox.Sound, 64 + Rockbox.Settings, 65 + Rockbox.System, 66 + Rockbox.Browse, 67 + Rockbox.Devices, 68 + Rockbox.Bluetooth 69 + ], 70 + Events: [Rockbox.Events, Rockbox.Socket], 71 + Plugins: [Rockbox.Plugin, Rockbox.Plugins], 72 + Builders: [Rockbox.SmartPlaylist.Rules] 73 + ] 74 + ] 75 + end 76 + end
+18
sdk/elixir/mix.lock
··· 1 + %{ 2 + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 3 + "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, 4 + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, 5 + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 6 + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 7 + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 8 + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 9 + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, 10 + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 11 + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 12 + "mint_web_socket": {:hex, :mint_web_socket, "1.0.5", "60354efeb49b1eccf95dfb75f55b08d692e211970fe735a5eb3188b328be2a90", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "04b35663448fc758f3356cce4d6ac067ca418bbafe6972a3805df984b5f12e61"}, 13 + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 14 + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 15 + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 16 + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, 17 + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, 18 + }
+15
sdk/elixir/test/rockbox/entry_test.exs
··· 1 + defmodule Rockbox.EntryTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Rockbox.Entry 5 + 6 + test "directory? checks the 0x10 bit" do 7 + assert Entry.directory?(%Entry{name: "x", attr: 0x10}) 8 + refute Entry.directory?(%Entry{name: "x", attr: 0x00}) 9 + end 10 + 11 + test "file? is the inverse" do 12 + assert Entry.file?(%Entry{name: "x", attr: 0x00}) 13 + refute Entry.file?(%Entry{name: "x", attr: 0x10}) 14 + end 15 + end
+32
sdk/elixir/test/rockbox/events_test.exs
··· 1 + defmodule Rockbox.EventsTest do 2 + use ExUnit.Case, async: false 3 + 4 + alias Rockbox.Events 5 + 6 + test "subscriber receives broadcast" do 7 + Events.subscribe(:track_changed) 8 + Events.broadcast(:track_changed, %Rockbox.Track{title: "Hello"}) 9 + assert_receive {:rockbox, :track_changed, %Rockbox.Track{title: "Hello"}}, 200 10 + end 11 + 12 + test ":all subscriber gets every event" do 13 + Events.subscribe(:all) 14 + Events.broadcast(:status_changed, :playing) 15 + assert_receive {:rockbox, :status_changed, :playing}, 200 16 + end 17 + 18 + test "unsubscribe stops delivery" do 19 + Events.subscribe(:track_changed) 20 + Events.unsubscribe(:track_changed) 21 + Events.broadcast(:track_changed, %Rockbox.Track{title: "ignored"}) 22 + refute_receive {:rockbox, :track_changed, _}, 100 23 + end 24 + 25 + test "subscribe/1 with a list" do 26 + Events.subscribe([:track_changed, :status_changed]) 27 + Events.broadcast(:status_changed, :playing) 28 + Events.broadcast(:track_changed, %Rockbox.Track{title: "x"}) 29 + assert_receive {:rockbox, :status_changed, :playing} 30 + assert_receive {:rockbox, :track_changed, %Rockbox.Track{}} 31 + end 32 + end
+59
sdk/elixir/test/rockbox/plugins_test.exs
··· 1 + defmodule Rockbox.PluginsTest do 2 + use ExUnit.Case, async: false 3 + 4 + defmodule HelloPlugin do 5 + @behaviour Rockbox.Plugin 6 + 7 + @impl true 8 + def name, do: "hello" 9 + @impl true 10 + def version, do: "0.1.0" 11 + @impl true 12 + def description, do: "Test plugin" 13 + 14 + @impl true 15 + def install(ctx) do 16 + {pid, _} = :persistent_term.get({__MODULE__, :test_pid}, {nil, nil}) 17 + if pid, do: send(pid, {:installed, ctx.client}) 18 + {:ok, %{installed_at: System.system_time()}} 19 + end 20 + 21 + @impl true 22 + def uninstall(_state) do 23 + {pid, _} = :persistent_term.get({__MODULE__, :test_pid}, {nil, nil}) 24 + if pid, do: send(pid, :uninstalled) 25 + :ok 26 + end 27 + end 28 + 29 + setup do 30 + :persistent_term.put({HelloPlugin, :test_pid}, {self(), make_ref()}) 31 + Rockbox.Plugins.uninstall("hello") 32 + 33 + on_exit(fn -> 34 + Rockbox.Plugins.uninstall("hello") 35 + end) 36 + 37 + :ok 38 + end 39 + 40 + test "install / list / uninstall" do 41 + client = Rockbox.new() 42 + 43 + assert :ok = Rockbox.use_plugin(client, HelloPlugin) 44 + assert_receive {:installed, %Rockbox.Client{}} 45 + 46 + names = Enum.map(Rockbox.installed_plugins(), & &1.module.name()) 47 + assert "hello" in names 48 + 49 + assert :ok = Rockbox.unuse_plugin("hello") 50 + assert_receive :uninstalled 51 + end 52 + 53 + test "double-install is rejected" do 54 + client = Rockbox.new() 55 + assert :ok = Rockbox.use_plugin(client, HelloPlugin) 56 + assert_receive {:installed, _} 57 + assert {:error, :already_installed} = Rockbox.use_plugin(client, HelloPlugin) 58 + end 59 + end
+51
sdk/elixir/test/rockbox/smart_playlist/rules_test.exs
··· 1 + defmodule Rockbox.SmartPlaylist.RulesTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Rockbox.SmartPlaylist.Rules 5 + 6 + test "all_of with where, sort, limit" do 7 + json = 8 + Rules.all_of() 9 + |> Rules.where(:play_count, :gte, 10) 10 + |> Rules.sort(:play_count, :desc) 11 + |> Rules.limit(50) 12 + |> Rules.to_json() 13 + 14 + decoded = Jason.decode!(json) 15 + assert decoded["operator"] == "AND" 16 + 17 + assert decoded["rules"] == [ 18 + %{"field" => "play_count", "op" => "gte", "value" => 10} 19 + ] 20 + 21 + assert decoded["sort"] == %{"field" => "play_count", "dir" => "desc"} 22 + assert decoded["limit"] == 50 23 + end 24 + 25 + test "any_of starts an OR group" do 26 + map = 27 + Rules.any_of() 28 + |> Rules.where(:title, :contains, "Live") 29 + |> Rules.where(:title, :contains, "Acoustic") 30 + |> Rules.to_map() 31 + 32 + assert map.operator == "OR" 33 + assert length(map.rules) == 2 34 + end 35 + 36 + test "where_group nests sub-builders" do 37 + sub = 38 + Rules.any_of() 39 + |> Rules.where(:genre, :eq, "jazz") 40 + |> Rules.where(:genre, :eq, "blues") 41 + 42 + map = 43 + Rules.all_of() 44 + |> Rules.where(:play_count, :gt, 0) 45 + |> Rules.where_group(sub) 46 + |> Rules.to_map() 47 + 48 + assert length(map.rules) == 2 49 + assert Enum.at(map.rules, 1).operator == "OR" 50 + end 51 + end
+41
sdk/elixir/test/rockbox/types_test.exs
··· 1 + defmodule Rockbox.TypesTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Rockbox.Types 5 + 6 + test "playback_status round-trips" do 7 + for atom <- [:stopped, :playing, :paused] do 8 + assert Types.playback_status(Types.from_playback_status(atom)) == atom 9 + end 10 + end 11 + 12 + test "repeat_mode round-trips" do 13 + for atom <- [:off, :all, :one, :shuffle, :ab_repeat] do 14 + assert Types.repeat_mode(Types.from_repeat_mode(atom)) == atom 15 + end 16 + end 17 + 18 + test "channel_config round-trips" do 19 + for atom <- [:stereo, :stereo_narrow, :mono, :left_mix, :right_mix, :karaoke] do 20 + assert Types.channel_config(Types.from_channel_config(atom)) == atom 21 + end 22 + end 23 + 24 + test "replaygain_type round-trips" do 25 + for atom <- [:track, :album, :shuffle] do 26 + assert Types.replaygain_type(Types.from_replaygain_type(atom)) == atom 27 + end 28 + end 29 + 30 + test "insert_position accepts atoms and ints" do 31 + assert Types.insert_position(:next) == 0 32 + assert Types.insert_position(:after_current) == 1 33 + assert Types.insert_position(:last) == 2 34 + assert Types.insert_position(:first) == 3 35 + assert Types.insert_position(7) == 7 36 + end 37 + 38 + test "unknown values surface as {:unknown, n}" do 39 + assert Types.playback_status(99) == {:unknown, 99} 40 + end 41 + end
+59
sdk/elixir/test/rockbox/util_test.exs
··· 1 + defmodule Rockbox.UtilTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Rockbox.Util 5 + 6 + describe "atomize/1" do 7 + test "converts camelCase string keys to snake_case atoms" do 8 + assert Util.atomize(%{"trackNum" => 1, "albumArt" => "x"}) == %{ 9 + track_num: 1, 10 + album_art: "x" 11 + } 12 + end 13 + 14 + test "recurses into nested maps" do 15 + input = %{"outer" => %{"innerKey" => 1}} 16 + assert Util.atomize(input) == %{outer: %{inner_key: 1}} 17 + end 18 + 19 + test "recurses into lists of maps" do 20 + input = [%{"firstName" => "a"}, %{"firstName" => "b"}] 21 + assert Util.atomize(input) == [%{first_name: "a"}, %{first_name: "b"}] 22 + end 23 + 24 + test "leaves non-map/list values alone" do 25 + assert Util.atomize("hi") == "hi" 26 + assert Util.atomize(42) == 42 27 + assert Util.atomize(nil) == nil 28 + end 29 + end 30 + 31 + describe "camelize/1" do 32 + test "converts snake_case atom keys to camelCase strings" do 33 + assert Util.camelize(%{album_id: "x", play_count: 5}) == 34 + %{"albumId" => "x", "playCount" => 5} 35 + end 36 + 37 + test "passes through values" do 38 + assert Util.camelize(%{name: "foo"}) == %{"name" => "foo"} 39 + end 40 + 41 + test "round-trips through atomize/1" do 42 + original = %{"trackNum" => 1, "nested" => %{"theKey" => 42}} 43 + assert original |> Util.atomize() |> Util.camelize() == original 44 + end 45 + end 46 + 47 + describe "to_struct/2" do 48 + test "builds a struct, ignoring unknown keys" do 49 + raw = %{"id" => "x", "title" => "Hi", "extraField" => "ignored"} 50 + track = Util.to_struct(Rockbox.Track, raw) 51 + assert track.id == "x" 52 + assert track.title == "Hi" 53 + end 54 + 55 + test "returns nil for nil input" do 56 + assert Util.to_struct(Rockbox.Track, nil) == nil 57 + end 58 + end 59 + end
+45
sdk/elixir/test/rockbox_test.exs
··· 1 + defmodule RockboxTest do 2 + use ExUnit.Case, async: true 3 + doctest Rockbox 4 + 5 + describe "new/1" do 6 + test "defaults" do 7 + client = Rockbox.new() 8 + assert client.host == "localhost" 9 + assert client.port == 6062 10 + assert client.http_url == "http://localhost:6062/graphql" 11 + assert client.ws_url == "ws://localhost:6062/graphql" 12 + end 13 + 14 + test "host + port override" do 15 + client = Rockbox.new(host: "192.168.1.10", port: 7000) 16 + assert client.http_url == "http://192.168.1.10:7000/graphql" 17 + assert client.ws_url == "ws://192.168.1.10:7000/graphql" 18 + end 19 + 20 + test "http_url override takes precedence" do 21 + client = Rockbox.new(http_url: "https://music.home/api") 22 + assert client.http_url == "https://music.home/api" 23 + end 24 + end 25 + 26 + describe "format_ms/1" do 27 + test "formats sub-minute durations" do 28 + assert Rockbox.format_ms(45_000) == "0:45" 29 + end 30 + 31 + test "formats minute-and-seconds" do 32 + assert Rockbox.format_ms(75_000) == "1:15" 33 + assert Rockbox.format_ms(180_000) == "3:00" 34 + end 35 + 36 + test "pads single-digit seconds" do 37 + assert Rockbox.format_ms(61_000) == "1:01" 38 + end 39 + 40 + test "negative or invalid -> 0:00" do 41 + assert Rockbox.format_ms(-1) == "0:00" 42 + assert Rockbox.format_ms(nil) == "0:00" 43 + end 44 + end 45 + end
+1
sdk/elixir/test/test_helper.exs
··· 1 + ExUnit.start()
+4
sdk/gleam/.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump
+21
sdk/gleam/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app> 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+30
sdk/gleam/README.md
··· 1 + # rockbox 2 + 3 + [![Package Version](https://img.shields.io/hexpm/v/rockbox)](https://hex.pm/packages/rockbox) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/rockbox/) 5 + 6 + ```sh 7 + gleam add rockbox@1 8 + ``` 9 + ```gleam 10 + import rockbox 11 + 12 + pub fn main() -> Nil { 13 + // TODO: An example of the project in use 14 + } 15 + ``` 16 + 17 + Further documentation can be found at <https://hexdocs.pm/rockbox>. 18 + 19 + ## Development 20 + 21 + ```sh 22 + gleam run # Run the project 23 + gleam test # Run the tests 24 + ``` 25 + 26 + --- 27 + 28 + ## License 29 + 30 + MIT License. See [LICENSE](./LICENSE) for details.
+16
sdk/gleam/gleam.toml
··· 1 + name = "rockbox" 2 + version = "1.0.0" 3 + description = "Gleam SDK for Rockbox — pipe-friendly client for the rockboxd GraphQL API" 4 + licences = ["Apache-2.0"] 5 + gleam = ">= 1.11.0" 6 + 7 + # repository = { type = "github", user = "tsirysndr", repo = "rockbox-zig" } 8 + 9 + [dependencies] 10 + gleam_stdlib = ">= 0.46.0 and < 2.0.0" 11 + gleam_http = ">= 4.0.0 and < 5.0.0" 12 + gleam_httpc = ">= 5.0.0 and < 6.0.0" 13 + gleam_json = ">= 2.0.0 and < 4.0.0" 14 + 15 + [dev_dependencies] 16 + gleeunit = ">= 1.0.0 and < 2.0.0"
+18
sdk/gleam/manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 6 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 7 + { 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" }, 8 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 9 + { name = "gleam_stdlib", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "960090C2FB391784BB34267B099DC9315CC1B1F6013E7415BC763CEF1905D7D3" }, 10 + { name = "gleeunit", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "254B697FE72EEAD7BF82E941723918E421317813AC49923EE76A18C788C61E72" }, 11 + ] 12 + 13 + [requirements] 14 + gleam_http = { version = ">= 4.0.0 and < 5.0.0" } 15 + gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } 16 + gleam_json = { version = ">= 2.0.0 and < 4.0.0" } 17 + gleam_stdlib = { version = ">= 0.46.0 and < 2.0.0" } 18 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+161
sdk/gleam/src/rockbox.gleam
··· 1 + //// Rockbox Gleam SDK — pipe-friendly client for the rockboxd GraphQL API. 2 + //// 3 + //// ```gleam 4 + //// import rockbox 5 + //// import rockbox/playback 6 + //// 7 + //// pub fn main() { 8 + //// let client = rockbox.connect() 9 + //// 10 + //// let assert Ok(track) = playback.current_track(client) 11 + //// let assert Ok(_) = playback.pause(client) 12 + //// } 13 + //// ``` 14 + //// 15 + //// Customise the connection with the builder: 16 + //// 17 + //// ```gleam 18 + //// let client = 19 + //// rockbox.new() 20 + //// |> rockbox.host("rockbox.local") 21 + //// |> rockbox.port(8080) 22 + //// |> rockbox.connect 23 + //// ``` 24 + 25 + import gleam/dynamic/decode 26 + import gleam/int 27 + import gleam/json.{type Json} 28 + import gleam/option.{type Option, None, Some} 29 + import rockbox/error.{type Error} 30 + import rockbox/internal/transport.{type Transport, Transport} 31 + 32 + // --------------------------------------------------------------------------- 33 + // Types 34 + // --------------------------------------------------------------------------- 35 + 36 + /// A configured client. Hand it to any of the per-domain API modules: 37 + /// 38 + /// ```gleam 39 + /// playback.play(client, 0, 0) 40 + /// library.search(client, "miles davis") 41 + /// ``` 42 + pub opaque type Client { 43 + Client(transport: Transport) 44 + } 45 + 46 + /// A fluent builder used to configure a `Client`. 47 + /// 48 + /// Construct with `new`, override defaults with `host` / `port` / `url`, then 49 + /// call `connect` (or `build`, the alias) to get a `Client`. 50 + pub opaque type Builder { 51 + Builder(host: String, port: Int, url_override: Option(String)) 52 + } 53 + 54 + // --------------------------------------------------------------------------- 55 + // Builder API 56 + // --------------------------------------------------------------------------- 57 + 58 + /// Start a new client builder with sensible defaults (`localhost:6062`). 59 + pub fn new() -> Builder { 60 + Builder(host: "localhost", port: 6062, url_override: None) 61 + } 62 + 63 + /// Override the hostname (default `"localhost"`). Ignored if `url` is set. 64 + pub fn host(builder: Builder, value: String) -> Builder { 65 + Builder(..builder, host: value) 66 + } 67 + 68 + /// Override the port (default `6062`). Ignored if `url` is set. 69 + pub fn port(builder: Builder, value: Int) -> Builder { 70 + Builder(..builder, port: value) 71 + } 72 + 73 + /// Override the full GraphQL HTTP URL. Takes precedence over `host` / `port`. 74 + pub fn url(builder: Builder, value: String) -> Builder { 75 + Builder(..builder, url_override: Some(value)) 76 + } 77 + 78 + /// Finalise the builder and return a usable `Client`. 79 + pub fn connect(builder: Builder) -> Client { 80 + let http_url = case builder.url_override { 81 + Some(value) -> value 82 + None -> 83 + "http://" 84 + <> builder.host 85 + <> ":" 86 + <> int.to_string(builder.port) 87 + <> "/graphql" 88 + } 89 + Client(transport: Transport(http_url: http_url)) 90 + } 91 + 92 + /// Alias for `connect`. Use whichever name reads better in your code. 93 + pub fn build(builder: Builder) -> Client { 94 + connect(builder) 95 + } 96 + 97 + // --------------------------------------------------------------------------- 98 + // Convenience constructors 99 + // --------------------------------------------------------------------------- 100 + 101 + /// Shortcut for `new() |> connect()` — a client pointed at `localhost:6062`. 102 + pub fn default_client() -> Client { 103 + new() |> connect 104 + } 105 + 106 + /// Shortcut for `new() |> host(host) |> port(port) |> connect()`. 107 + pub fn at(host h: String, port p: Int) -> Client { 108 + new() |> host(h) |> port(p) |> connect 109 + } 110 + 111 + // --------------------------------------------------------------------------- 112 + // Escape hatches & accessors 113 + // --------------------------------------------------------------------------- 114 + 115 + /// Run a raw GraphQL operation against the server, decoding the `data` field 116 + /// with the supplied decoder. Useful if you need an endpoint the SDK doesn't 117 + /// expose directly yet. 118 + /// 119 + /// ```gleam 120 + /// import gleam/dynamic/decode 121 + /// import gleam/json 122 + /// 123 + /// let decoder = { 124 + /// use version <- decode.field("rockboxVersion", decode.string) 125 + /// decode.success(version) 126 + /// } 127 + /// 128 + /// let assert Ok(version) = 129 + /// client 130 + /// |> rockbox.query("query { rockboxVersion }", json.object([]), decoder) 131 + /// ``` 132 + pub fn query( 133 + client: Client, 134 + gql: String, 135 + variables: Json, 136 + decoder: decode.Decoder(t), 137 + ) -> Result(t, Error) { 138 + transport.execute(client.transport, gql, variables, decoder) 139 + } 140 + 141 + /// Like `query` but discards the response body — handy for fire-and-forget 142 + /// mutations that return a boolean status flag you don't care about. 143 + pub fn execute( 144 + client: Client, 145 + gql: String, 146 + variables: Json, 147 + ) -> Result(Nil, Error) { 148 + transport.execute_unit(client.transport, gql, variables) 149 + } 150 + 151 + /// Return the underlying GraphQL HTTP URL. Useful for diagnostics & tests. 152 + pub fn http_url(client: Client) -> String { 153 + client.transport.http_url 154 + } 155 + 156 + /// Internal: hand the underlying transport to other modules in the SDK. 157 + @internal 158 + pub fn transport(client: Client) -> Transport { 159 + client.transport 160 + } 161 +
+71
sdk/gleam/src/rockbox/bluetooth.gleam
··· 1 + //// Bluetooth device pairing and connection (Linux only). 2 + 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import gleam/option.{type Option} 6 + import rockbox.{type Client} 7 + import rockbox/error.{type Error} 8 + import rockbox/internal/transport 9 + import rockbox/types.{type BluetoothDevice} 10 + 11 + const bluetooth_fields = " 12 + fragment BluetoothDeviceFields on BluetoothDevice { 13 + address name paired trusted connected rssi 14 + } 15 + " 16 + 17 + /// Known/paired devices. 18 + pub fn devices(client: Client) -> Result(List(BluetoothDevice), Error) { 19 + let decoder = { 20 + use devices <- decode.field( 21 + "bluetoothDevices", 22 + decode.list(types.bluetooth_device_decoder()), 23 + ) 24 + decode.success(devices) 25 + } 26 + let q = bluetooth_fields <> " 27 + query BluetoothDevices { bluetoothDevices { ...BluetoothDeviceFields } } 28 + " 29 + rockbox.query(client, q, json.object([]), decoder) 30 + } 31 + 32 + /// Trigger an active scan. `timeout_secs` of `option.None` uses the firmware 33 + /// default. 34 + pub fn scan( 35 + client: Client, 36 + timeout_secs: Option(Int), 37 + ) -> Result(List(BluetoothDevice), Error) { 38 + let decoder = { 39 + use devices <- decode.field( 40 + "bluetoothScan", 41 + decode.list(types.bluetooth_device_decoder()), 42 + ) 43 + decode.success(devices) 44 + } 45 + let vars = 46 + transport.variables([ 47 + #("timeoutSecs", option.map(timeout_secs, json.int)), 48 + ]) 49 + let q = bluetooth_fields <> " 50 + mutation BluetoothScan($timeoutSecs: Int) { 51 + bluetoothScan(timeoutSecs: $timeoutSecs) { ...BluetoothDeviceFields } 52 + } 53 + " 54 + rockbox.query(client, q, vars, decoder) 55 + } 56 + 57 + pub fn connect(client: Client, address: String) -> Result(Nil, Error) { 58 + rockbox.execute( 59 + client, 60 + "mutation BluetoothConnect($address: String!) { bluetoothConnect(address: $address) }", 61 + json.object([#("address", json.string(address))]), 62 + ) 63 + } 64 + 65 + pub fn disconnect(client: Client, address: String) -> Result(Nil, Error) { 66 + rockbox.execute( 67 + client, 68 + "mutation BluetoothDisconnect($address: String!) { bluetoothDisconnect(address: $address) }", 69 + json.object([#("address", json.string(address))]), 70 + ) 71 + }
+56
sdk/gleam/src/rockbox/browse.gleam
··· 1 + //// File-tree browsing for both the local filesystem and UPnP servers. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import gleam/list 6 + import gleam/option.{type Option} 7 + import rockbox.{type Client} 8 + import rockbox/error.{type Error} 9 + import rockbox/internal/transport 10 + import rockbox/types.{type Entry} 11 + 12 + /// Both directories and files at `path`. Pass `option.None` to read the root. 13 + pub fn entries( 14 + client: Client, 15 + path: Option(String), 16 + ) -> Result(List(Entry), Error) { 17 + let decoder = { 18 + use entries <- decode.field( 19 + "treeGetEntries", 20 + decode.list(types.entry_decoder()), 21 + ) 22 + decode.success(entries) 23 + } 24 + let vars = transport.variables([#("path", option.map(path, json.string))]) 25 + rockbox.query( 26 + client, 27 + "query Browse($path: String) { 28 + treeGetEntries(path: $path) { name attr timeWrite customaction displayName } 29 + }", 30 + vars, 31 + decoder, 32 + ) 33 + } 34 + 35 + /// Subdirectories only. 36 + pub fn directories( 37 + client: Client, 38 + path: Option(String), 39 + ) -> Result(List(Entry), Error) { 40 + case entries(client, path) { 41 + Ok(all) -> Ok(list.filter(all, types.is_directory)) 42 + Error(err) -> Error(err) 43 + } 44 + } 45 + 46 + /// Files only (everything that isn't a directory). 47 + pub fn files( 48 + client: Client, 49 + path: Option(String), 50 + ) -> Result(List(Entry), Error) { 51 + case entries(client, path) { 52 + Ok(all) -> 53 + Ok(list.filter(all, fn(e) { !types.is_directory(e) })) 54 + Error(err) -> Error(err) 55 + } 56 + }
+58
sdk/gleam/src/rockbox/devices.gleam
··· 1 + //// Discovery and routing for Chromecast / AirPlay / UPnP / Snapcast endpoints. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import gleam/option.{type Option} 6 + import rockbox.{type Client} 7 + import rockbox/error.{type Error} 8 + import rockbox/types.{type Device} 9 + 10 + const device_fields = " 11 + id name host ip port service app isConnected 12 + baseUrl isCastDevice isSourceDevice isCurrentDevice 13 + " 14 + 15 + pub fn list(client: Client) -> Result(List(Device), Error) { 16 + let decoder = { 17 + use devices <- decode.field("devices", decode.list(types.device_decoder())) 18 + decode.success(devices) 19 + } 20 + rockbox.query( 21 + client, 22 + "query Devices { devices { " <> device_fields <> " } }", 23 + json.object([]), 24 + decoder, 25 + ) 26 + } 27 + 28 + pub fn get(client: Client, id: String) -> Result(Option(Device), Error) { 29 + let decoder = { 30 + use device <- decode.field( 31 + "device", 32 + decode.optional(types.device_decoder()), 33 + ) 34 + decode.success(device) 35 + } 36 + rockbox.query( 37 + client, 38 + "query Device($id: String!) { device(id: $id) { " <> device_fields <> " } }", 39 + json.object([#("id", json.string(id))]), 40 + decoder, 41 + ) 42 + } 43 + 44 + pub fn connect(client: Client, id: String) -> Result(Nil, Error) { 45 + rockbox.execute( 46 + client, 47 + "mutation ConnectDevice($id: String!) { connect(id: $id) }", 48 + json.object([#("id", json.string(id))]), 49 + ) 50 + } 51 + 52 + pub fn disconnect(client: Client, id: String) -> Result(Nil, Error) { 53 + rockbox.execute( 54 + client, 55 + "mutation DisconnectDevice($id: String!) { disconnect(id: $id) }", 56 + json.object([#("id", json.string(id))]), 57 + ) 58 + }
+34
sdk/gleam/src/rockbox/error.gleam
··· 1 + import gleam/json 2 + import gleam/string 3 + 4 + /// Errors returned from any SDK call. 5 + /// 6 + /// Pattern-match in your callers to react differently to network problems vs. 7 + /// upstream GraphQL errors: 8 + /// 9 + /// ```gleam 10 + /// case playback.current_track(client) { 11 + /// Ok(track) -> echo track 12 + /// Error(error.NetworkError(_)) -> retry() 13 + /// Error(error.GraphQLError(messages)) -> log_errors(messages) 14 + /// Error(_) -> Nil 15 + /// } 16 + /// ``` 17 + pub type Error { 18 + /// Could not reach the server (DNS, refused connection, TLS, etc). 19 + NetworkError(reason: String) 20 + 21 + /// Server returned a non-2xx HTTP response. 22 + HttpError(status: Int, body: String) 23 + 24 + /// GraphQL endpoint returned a populated `errors` array. 25 + GraphQLError(messages: List(String)) 26 + 27 + /// Response body could not be decoded into the expected shape. 28 + DecodeError(reason: String) 29 + } 30 + 31 + @internal 32 + pub fn from_json_decode_error(err: json.DecodeError) -> Error { 33 + DecodeError(string.inspect(err)) 34 + }
+106
sdk/gleam/src/rockbox/internal/transport.gleam
··· 1 + //// Internal HTTP transport for the Rockbox GraphQL endpoint. 2 + //// 3 + //// Not part of the public API — call sites should use the per-domain 4 + //// modules under `rockbox/*`. 5 + 6 + import gleam/dynamic 7 + import gleam/dynamic/decode 8 + import gleam/http.{Post} 9 + import gleam/http/request 10 + import gleam/httpc 11 + import gleam/json.{type Json} 12 + import gleam/list 13 + import gleam/option.{type Option, None, Some} 14 + import gleam/result 15 + import gleam/string 16 + import rockbox/error.{type Error} 17 + 18 + pub type Transport { 19 + Transport(http_url: String) 20 + } 21 + 22 + /// Build the JSON variables payload from a list of optional pairs. 23 + /// 24 + /// Pairs whose value is `None` are dropped, so the GraphQL endpoint never sees 25 + /// e.g. `"description": null` for an unset optional argument. 26 + pub fn variables(pairs: List(#(String, Option(Json)))) -> Json { 27 + pairs 28 + |> list.filter_map(fn(pair) { 29 + let #(key, value) = pair 30 + case value { 31 + Some(json) -> Ok(#(key, json)) 32 + None -> Error(Nil) 33 + } 34 + }) 35 + |> json.object 36 + } 37 + 38 + /// Execute a GraphQL operation. The response is decoded with `decoder` against 39 + /// the contents of the top-level `data` field. 40 + pub fn execute( 41 + transport: Transport, 42 + query: String, 43 + vars: Json, 44 + decoder: decode.Decoder(t), 45 + ) -> Result(t, Error) { 46 + let body = 47 + json.object([#("query", json.string(query)), #("variables", vars)]) 48 + |> json.to_string 49 + 50 + use req <- result.try( 51 + request.to(transport.http_url) 52 + |> result.replace_error(error.NetworkError( 53 + "invalid URL: " <> transport.http_url, 54 + )), 55 + ) 56 + 57 + let req = 58 + req 59 + |> request.set_method(Post) 60 + |> request.set_header("content-type", "application/json") 61 + |> request.set_header("accept", "application/json") 62 + |> request.set_body(body) 63 + 64 + use response <- result.try( 65 + httpc.send(req) 66 + |> result.map_error(fn(err) { error.NetworkError(string.inspect(err)) }), 67 + ) 68 + 69 + case response.status { 70 + s if s >= 200 && s < 300 -> decode_response(response.body, decoder) 71 + s -> Error(error.HttpError(s, response.body)) 72 + } 73 + } 74 + 75 + /// A query returning no useful data — discards the decoded value. 76 + pub fn execute_unit( 77 + transport: Transport, 78 + query: String, 79 + vars: Json, 80 + ) -> Result(Nil, Error) { 81 + execute(transport, query, vars, decode.dynamic) 82 + |> result.replace(Nil) 83 + } 84 + 85 + fn decode_response( 86 + body: String, 87 + decoder: decode.Decoder(t), 88 + ) -> Result(t, Error) { 89 + let envelope_decoder = { 90 + use errors <- decode.optional_field( 91 + "errors", 92 + [], 93 + decode.list(decode.at(["message"], decode.string)), 94 + ) 95 + use data <- decode.optional_field("data", dynamic.nil(), decode.dynamic) 96 + decode.success(#(errors, data)) 97 + } 98 + 99 + case json.parse(body, envelope_decoder) { 100 + Ok(#([], data)) -> 101 + decode.run(data, decoder) 102 + |> result.map_error(fn(errs) { error.DecodeError(string.inspect(errs)) }) 103 + Ok(#(messages, _)) -> Error(error.GraphQLError(messages)) 104 + Error(err) -> Error(error.from_json_decode_error(err)) 105 + } 106 + }
+234
sdk/gleam/src/rockbox/library.gleam
··· 1 + //// Browse and search the indexed music library. 2 + //// 3 + //// ```gleam 4 + //// let assert Ok(albums) = library.albums(client) 5 + //// let assert Ok(results) = library.search(client, "miles davis") 6 + //// ``` 7 + 8 + import gleam/dynamic/decode 9 + import gleam/json 10 + import gleam/option.{type Option} 11 + import rockbox.{type Client} 12 + import rockbox/error.{type Error} 13 + import rockbox/types.{ 14 + type Album, type Artist, type SearchResults, type Track, 15 + } 16 + 17 + const track_fields = " 18 + fragment TrackFields on Track { 19 + id title artist album genre disc trackString yearString 20 + composer comment albumArtist grouping 21 + discnum tracknum layer year bitrate frequency 22 + filesize length elapsed path 23 + albumId artistId genreId albumArt 24 + } 25 + " 26 + 27 + const album_fields = " 28 + fragment AlbumFields on Album { 29 + id title artist year yearString albumArt md5 artistId copyrightMessage 30 + } 31 + " 32 + 33 + const artist_fields = " 34 + fragment ArtistFields on Artist { 35 + id name bio image 36 + } 37 + " 38 + 39 + // --------------------------------------------------------------------------- 40 + // Albums 41 + // --------------------------------------------------------------------------- 42 + 43 + pub fn albums(client: Client) -> Result(List(Album), Error) { 44 + let decoder = { 45 + use albums <- decode.field("albums", decode.list(types.album_decoder())) 46 + decode.success(albums) 47 + } 48 + let q = album_fields <> " 49 + query Albums { 50 + albums { ...AlbumFields tracks { id title path length albumArt } } 51 + } 52 + " 53 + rockbox.query(client, q, json.object([]), decoder) 54 + } 55 + 56 + pub fn album(client: Client, id: String) -> Result(Option(Album), Error) { 57 + let decoder = { 58 + use album <- decode.field( 59 + "album", 60 + decode.optional(types.album_decoder()), 61 + ) 62 + decode.success(album) 63 + } 64 + let q = track_fields <> album_fields <> " 65 + query Album($id: String!) { 66 + album(id: $id) { ...AlbumFields tracks { ...TrackFields } } 67 + } 68 + " 69 + rockbox.query(client, q, json.object([#("id", json.string(id))]), decoder) 70 + } 71 + 72 + pub fn liked_albums(client: Client) -> Result(List(Album), Error) { 73 + let decoder = { 74 + use albums <- decode.field( 75 + "likedAlbums", 76 + decode.list(types.album_decoder()), 77 + ) 78 + decode.success(albums) 79 + } 80 + let q = album_fields <> " 81 + query LikedAlbums { likedAlbums { ...AlbumFields } } 82 + " 83 + rockbox.query(client, q, json.object([]), decoder) 84 + } 85 + 86 + pub fn like_album(client: Client, id: String) -> Result(Nil, Error) { 87 + rockbox.execute( 88 + client, 89 + "mutation LikeAlbum($id: String!) { likeAlbum(id: $id) }", 90 + json.object([#("id", json.string(id))]), 91 + ) 92 + } 93 + 94 + pub fn unlike_album(client: Client, id: String) -> Result(Nil, Error) { 95 + rockbox.execute( 96 + client, 97 + "mutation UnlikeAlbum($id: String!) { unlikeAlbum(id: $id) }", 98 + json.object([#("id", json.string(id))]), 99 + ) 100 + } 101 + 102 + // --------------------------------------------------------------------------- 103 + // Artists 104 + // --------------------------------------------------------------------------- 105 + 106 + pub fn artists(client: Client) -> Result(List(Artist), Error) { 107 + let decoder = { 108 + use artists <- decode.field("artists", decode.list(types.artist_decoder())) 109 + decode.success(artists) 110 + } 111 + let q = artist_fields <> " 112 + query Artists { 113 + artists { ...ArtistFields albums { id title albumArt year } } 114 + } 115 + " 116 + rockbox.query(client, q, json.object([]), decoder) 117 + } 118 + 119 + pub fn artist(client: Client, id: String) -> Result(Option(Artist), Error) { 120 + let decoder = { 121 + use artist <- decode.field( 122 + "artist", 123 + decode.optional(types.artist_decoder()), 124 + ) 125 + decode.success(artist) 126 + } 127 + let q = artist_fields <> track_fields <> " 128 + query Artist($id: String!) { 129 + artist(id: $id) { 130 + ...ArtistFields 131 + albums { 132 + id title albumArt year yearString md5 artistId 133 + tracks { id title path length } 134 + } 135 + tracks { ...TrackFields } 136 + } 137 + } 138 + " 139 + rockbox.query(client, q, json.object([#("id", json.string(id))]), decoder) 140 + } 141 + 142 + // --------------------------------------------------------------------------- 143 + // Tracks 144 + // --------------------------------------------------------------------------- 145 + 146 + pub fn tracks(client: Client) -> Result(List(Track), Error) { 147 + let decoder = { 148 + use tracks <- decode.field("tracks", decode.list(types.track_decoder())) 149 + decode.success(tracks) 150 + } 151 + let q = track_fields <> " 152 + query Tracks { tracks { ...TrackFields } } 153 + " 154 + rockbox.query(client, q, json.object([]), decoder) 155 + } 156 + 157 + pub fn track(client: Client, id: String) -> Result(Option(Track), Error) { 158 + let decoder = { 159 + use track <- decode.field( 160 + "track", 161 + decode.optional(types.track_decoder()), 162 + ) 163 + decode.success(track) 164 + } 165 + let q = track_fields <> " 166 + query Track($id: String!) { track(id: $id) { ...TrackFields } } 167 + " 168 + rockbox.query(client, q, json.object([#("id", json.string(id))]), decoder) 169 + } 170 + 171 + pub fn liked_tracks(client: Client) -> Result(List(Track), Error) { 172 + let decoder = { 173 + use tracks <- decode.field( 174 + "likedTracks", 175 + decode.list(types.track_decoder()), 176 + ) 177 + decode.success(tracks) 178 + } 179 + let q = track_fields <> " 180 + query LikedTracks { likedTracks { ...TrackFields } } 181 + " 182 + rockbox.query(client, q, json.object([]), decoder) 183 + } 184 + 185 + pub fn like_track(client: Client, id: String) -> Result(Nil, Error) { 186 + rockbox.execute( 187 + client, 188 + "mutation LikeTrack($id: String!) { likeTrack(id: $id) }", 189 + json.object([#("id", json.string(id))]), 190 + ) 191 + } 192 + 193 + pub fn unlike_track(client: Client, id: String) -> Result(Nil, Error) { 194 + rockbox.execute( 195 + client, 196 + "mutation UnlikeTrack($id: String!) { unlikeTrack(id: $id) }", 197 + json.object([#("id", json.string(id))]), 198 + ) 199 + } 200 + 201 + // --------------------------------------------------------------------------- 202 + // Search 203 + // --------------------------------------------------------------------------- 204 + 205 + pub fn search(client: Client, term: String) -> Result(SearchResults, Error) { 206 + let decoder = { 207 + use results <- decode.field("search", types.search_results_decoder()) 208 + decode.success(results) 209 + } 210 + let q = track_fields <> album_fields <> artist_fields <> " 211 + query Search($term: String!) { 212 + search(term: $term) { 213 + artists { ...ArtistFields } 214 + albums { ...AlbumFields } 215 + tracks { ...TrackFields } 216 + likedTracks { ...TrackFields } 217 + likedAlbums { ...AlbumFields } 218 + } 219 + } 220 + " 221 + rockbox.query(client, q, json.object([#("term", json.string(term))]), decoder) 222 + } 223 + 224 + // --------------------------------------------------------------------------- 225 + // Library management 226 + // --------------------------------------------------------------------------- 227 + 228 + pub fn scan(client: Client) -> Result(Nil, Error) { 229 + rockbox.execute( 230 + client, 231 + "mutation ScanLibrary { scanLibrary }", 232 + json.object([]), 233 + ) 234 + }
+322
sdk/gleam/src/rockbox/playback.gleam
··· 1 + //// Transport controls and "play this thing" shortcuts. 2 + //// 3 + //// ```gleam 4 + //// import rockbox 5 + //// import rockbox/playback 6 + //// 7 + //// let client = rockbox.connect(rockbox.new()) 8 + //// 9 + //// let _ = playback.play_track(client, "/Music/Miles Davis/Kind of Blue/01.flac") 10 + //// let _ = playback.pause(client) 11 + //// let _ = playback.next(client) 12 + //// ``` 13 + 14 + import gleam/dynamic/decode 15 + import gleam/json 16 + import gleam/option.{type Option, None, Some} 17 + import rockbox.{type Client} 18 + import rockbox/error.{type Error} 19 + import rockbox/internal/transport 20 + import rockbox/types.{type PlaybackStatus, type Track} 21 + 22 + const track_fields = " 23 + fragment TrackFields on Track { 24 + id title artist album genre disc trackString yearString 25 + composer comment albumArtist grouping 26 + discnum tracknum layer year bitrate frequency 27 + filesize length elapsed path 28 + albumId artistId genreId albumArt 29 + } 30 + " 31 + 32 + // --------------------------------------------------------------------------- 33 + // Status & current track 34 + // --------------------------------------------------------------------------- 35 + 36 + /// Raw numeric playback status as the firmware reports it. 37 + pub fn raw_status(client: Client) -> Result(Int, Error) { 38 + let decoder = { 39 + use status <- decode.field("status", decode.int) 40 + decode.success(status) 41 + } 42 + rockbox.query(client, "query PlaybackStatus { status }", json.object([]), decoder) 43 + } 44 + 45 + /// Typed playback status (`Stopped`, `Playing`, `Paused`, …). 46 + pub fn status(client: Client) -> Result(PlaybackStatus, Error) { 47 + case raw_status(client) { 48 + Ok(value) -> Ok(types.playback_status_from_int(value)) 49 + Error(err) -> Error(err) 50 + } 51 + } 52 + 53 + /// The track currently loaded for playback (may not be playing). Returns 54 + /// `Ok(None)` if no track is queued. 55 + pub fn current_track(client: Client) -> Result(Option(Track), Error) { 56 + let decoder = { 57 + use track <- decode.field( 58 + "currentTrack", 59 + decode.optional(types.track_decoder()), 60 + ) 61 + decode.success(track) 62 + } 63 + let q = track_fields <> " 64 + query CurrentTrack { currentTrack { ...TrackFields } } 65 + " 66 + rockbox.query(client, q, json.object([]), decoder) 67 + } 68 + 69 + /// The track that will play after the current one finishes. `Ok(None)` if the 70 + /// queue ends with the current track. 71 + pub fn next_track(client: Client) -> Result(Option(Track), Error) { 72 + let decoder = { 73 + use track <- decode.field( 74 + "nextTrack", 75 + decode.optional(types.track_decoder()), 76 + ) 77 + decode.success(track) 78 + } 79 + let q = track_fields <> " 80 + query NextTrack { nextTrack { ...TrackFields } } 81 + " 82 + rockbox.query(client, q, json.object([]), decoder) 83 + } 84 + 85 + /// Position of the audio file the codec is currently reading from, in bytes. 86 + pub fn file_position(client: Client) -> Result(Int, Error) { 87 + let decoder = { 88 + use pos <- decode.field("getFilePosition", decode.int) 89 + decode.success(pos) 90 + } 91 + rockbox.query( 92 + client, 93 + "query FilePosition { getFilePosition }", 94 + json.object([]), 95 + decoder, 96 + ) 97 + } 98 + 99 + // --------------------------------------------------------------------------- 100 + // Transport controls 101 + // --------------------------------------------------------------------------- 102 + 103 + /// Start playback at `elapsed` ms with the codec offset set to `offset` bytes. 104 + /// Pass `0, 0` to start from the beginning. 105 + pub fn play(client: Client, elapsed: Int, offset: Int) -> Result(Nil, Error) { 106 + rockbox.execute( 107 + client, 108 + "mutation Play($elapsed: Long!, $offset: Long!) { play(elapsed: $elapsed, offset: $offset) }", 109 + json.object([ 110 + #("elapsed", json.int(elapsed)), 111 + #("offset", json.int(offset)), 112 + ]), 113 + ) 114 + } 115 + 116 + /// Pause playback. 117 + pub fn pause(client: Client) -> Result(Nil, Error) { 118 + rockbox.execute(client, "mutation Pause { pause }", json.object([])) 119 + } 120 + 121 + /// Resume playback after a pause. 122 + pub fn resume(client: Client) -> Result(Nil, Error) { 123 + rockbox.execute(client, "mutation Resume { resume }", json.object([])) 124 + } 125 + 126 + /// Skip to the next track in the queue. 127 + pub fn next(client: Client) -> Result(Nil, Error) { 128 + rockbox.execute(client, "mutation Next { next }", json.object([])) 129 + } 130 + 131 + /// Go back to the previous track. 132 + pub fn previous(client: Client) -> Result(Nil, Error) { 133 + rockbox.execute(client, "mutation Previous { previous }", json.object([])) 134 + } 135 + 136 + /// Seek the current track to an absolute position in milliseconds. 137 + pub fn seek(client: Client, position_ms: Int) -> Result(Nil, Error) { 138 + rockbox.execute( 139 + client, 140 + "mutation Seek($newTime: Int!) { fastForwardRewind(newTime: $newTime) }", 141 + json.object([#("newTime", json.int(position_ms))]), 142 + ) 143 + } 144 + 145 + /// Stop playback and tear down the audio engine. 146 + pub fn stop(client: Client) -> Result(Nil, Error) { 147 + rockbox.execute(client, "mutation Stop { hardStop }", json.object([])) 148 + } 149 + 150 + /// Flush the codec buffer and reload the queue from disk. 151 + pub fn flush_and_reload(client: Client) -> Result(Nil, Error) { 152 + rockbox.execute( 153 + client, 154 + "mutation FlushReload { flushAndReloadTracks }", 155 + json.object([]), 156 + ) 157 + } 158 + 159 + // --------------------------------------------------------------------------- 160 + // Play helpers — single-call shortcuts 161 + // --------------------------------------------------------------------------- 162 + 163 + /// Play a single file by absolute path. 164 + pub fn play_track(client: Client, path: String) -> Result(Nil, Error) { 165 + rockbox.execute( 166 + client, 167 + "mutation PlayTrack($path: String!) { playTrack(path: $path) }", 168 + json.object([#("path", json.string(path))]), 169 + ) 170 + } 171 + 172 + /// Optional knobs accepted by every `play_*` shortcut. 173 + /// 174 + /// Build one with `play_options()` and chain `with_shuffle` / `with_position`: 175 + /// 176 + /// ```gleam 177 + /// let opts = 178 + /// playback.play_options() 179 + /// |> playback.with_shuffle(True) 180 + /// |> playback.with_position(2) 181 + /// 182 + /// let _ = playback.play_album(client, "abc-123", opts) 183 + /// ``` 184 + pub opaque type PlayOptions { 185 + PlayOptions(shuffle: Option(Bool), position: Option(Int)) 186 + } 187 + 188 + /// Default play options — no shuffle, append at the end. 189 + pub fn play_options() -> PlayOptions { 190 + PlayOptions(shuffle: None, position: None) 191 + } 192 + 193 + /// Toggle shuffle for the resulting queue. 194 + pub fn with_shuffle(opts: PlayOptions, value: Bool) -> PlayOptions { 195 + PlayOptions(..opts, shuffle: Some(value)) 196 + } 197 + 198 + /// Set the queue position to start playback at. 199 + pub fn with_position(opts: PlayOptions, value: Int) -> PlayOptions { 200 + PlayOptions(..opts, position: Some(value)) 201 + } 202 + 203 + fn play_options_pairs( 204 + opts: PlayOptions, 205 + ) -> List(#(String, Option(json.Json))) { 206 + [ 207 + #("shuffle", option.map(opts.shuffle, json.bool)), 208 + #("position", option.map(opts.position, json.int)), 209 + ] 210 + } 211 + 212 + /// Replace the queue with every track on an album and start playing. 213 + pub fn play_album( 214 + client: Client, 215 + album_id: String, 216 + options: PlayOptions, 217 + ) -> Result(Nil, Error) { 218 + let vars = 219 + transport.variables([ 220 + #("albumId", Some(json.string(album_id))), 221 + ..play_options_pairs(options) 222 + ]) 223 + rockbox.execute( 224 + client, 225 + "mutation PlayAlbum($albumId: String!, $shuffle: Boolean, $position: Int) { 226 + playAlbum(albumId: $albumId, shuffle: $shuffle, position: $position) 227 + }", 228 + vars, 229 + ) 230 + } 231 + 232 + /// Replace the queue with every track an artist has and start playing. 233 + pub fn play_artist( 234 + client: Client, 235 + artist_id: String, 236 + options: PlayOptions, 237 + ) -> Result(Nil, Error) { 238 + let vars = 239 + transport.variables([ 240 + #("artistId", Some(json.string(artist_id))), 241 + ..play_options_pairs(options) 242 + ]) 243 + rockbox.execute( 244 + client, 245 + "mutation PlayArtist($artistId: String!, $shuffle: Boolean, $position: Int) { 246 + playArtistTracks(artistId: $artistId, shuffle: $shuffle, position: $position) 247 + }", 248 + vars, 249 + ) 250 + } 251 + 252 + /// Play a saved playlist by ID. 253 + pub fn play_playlist( 254 + client: Client, 255 + playlist_id: String, 256 + options: PlayOptions, 257 + ) -> Result(Nil, Error) { 258 + let vars = 259 + transport.variables([ 260 + #("playlistId", Some(json.string(playlist_id))), 261 + ..play_options_pairs(options) 262 + ]) 263 + rockbox.execute( 264 + client, 265 + "mutation PlayPlaylist($playlistId: String!, $shuffle: Boolean, $position: Int) { 266 + playPlaylist(playlistId: $playlistId, shuffle: $shuffle, position: $position) 267 + }", 268 + vars, 269 + ) 270 + } 271 + 272 + /// Queue and play every audio file in a directory. 273 + pub fn play_directory( 274 + client: Client, 275 + path: String, 276 + recurse: Bool, 277 + options: PlayOptions, 278 + ) -> Result(Nil, Error) { 279 + let vars = 280 + transport.variables([ 281 + #("path", Some(json.string(path))), 282 + #("recurse", Some(json.bool(recurse))), 283 + ..play_options_pairs(options) 284 + ]) 285 + rockbox.execute( 286 + client, 287 + "mutation PlayDirectory($path: String!, $recurse: Boolean, $shuffle: Boolean, $position: Int) { 288 + playDirectory(path: $path, recurse: $recurse, shuffle: $shuffle, position: $position) 289 + }", 290 + vars, 291 + ) 292 + } 293 + 294 + /// Play every liked track. 295 + pub fn play_liked_tracks( 296 + client: Client, 297 + options: PlayOptions, 298 + ) -> Result(Nil, Error) { 299 + let vars = transport.variables(play_options_pairs(options)) 300 + rockbox.execute( 301 + client, 302 + "mutation PlayLikedTracks($shuffle: Boolean, $position: Int) { 303 + playLikedTracks(shuffle: $shuffle, position: $position) 304 + }", 305 + vars, 306 + ) 307 + } 308 + 309 + /// Play the entire library. 310 + pub fn play_all_tracks( 311 + client: Client, 312 + options: PlayOptions, 313 + ) -> Result(Nil, Error) { 314 + let vars = transport.variables(play_options_pairs(options)) 315 + rockbox.execute( 316 + client, 317 + "mutation PlayAllTracks($shuffle: Boolean, $position: Int) { 318 + playAllTracks(shuffle: $shuffle, position: $position) 319 + }", 320 + vars, 321 + ) 322 + }
+222
sdk/gleam/src/rockbox/playlist.gleam
··· 1 + //// The current/active playback queue (Mopidy-style "tracklist"). 2 + //// 3 + //// For named, persisted playlists see `rockbox/saved_playlists`. 4 + 5 + import gleam/dynamic/decode 6 + import gleam/json 7 + import gleam/option.{type Option, None, Some} 8 + import rockbox.{type Client} 9 + import rockbox/error.{type Error} 10 + import rockbox/internal/transport 11 + import rockbox/types.{type InsertPosition, type Playlist} 12 + 13 + const track_fields = " 14 + fragment TrackFields on Track { 15 + id title artist album genre disc trackString yearString 16 + composer comment albumArtist grouping 17 + discnum tracknum layer year bitrate frequency 18 + filesize length elapsed path 19 + albumId artistId genreId albumArt 20 + } 21 + " 22 + 23 + /// Snapshot of the active queue. 24 + pub fn current(client: Client) -> Result(Playlist, Error) { 25 + let decoder = { 26 + use playlist <- decode.field( 27 + "playlistGetCurrent", 28 + types.playlist_decoder(), 29 + ) 30 + decode.success(playlist) 31 + } 32 + let q = track_fields <> " 33 + query CurrentPlaylist { 34 + playlistGetCurrent { 35 + amount index maxPlaylistSize firstIndex 36 + lastInsertPos seed lastShuffledStart 37 + tracks { ...TrackFields } 38 + } 39 + } 40 + " 41 + rockbox.query(client, q, json.object([]), decoder) 42 + } 43 + 44 + /// Number of tracks currently queued. 45 + pub fn amount(client: Client) -> Result(Int, Error) { 46 + let decoder = { 47 + use n <- decode.field("playlistAmount", decode.int) 48 + decode.success(n) 49 + } 50 + rockbox.query( 51 + client, 52 + "query PlaylistAmount { playlistAmount }", 53 + json.object([]), 54 + decoder, 55 + ) 56 + } 57 + 58 + // --------------------------------------------------------------------------- 59 + // Queue mutations 60 + // --------------------------------------------------------------------------- 61 + 62 + /// Insert a list of paths or track IDs into the queue at the given position. 63 + /// 64 + /// Pass `option.None` for `playlist_id` to target the active queue. 65 + pub fn insert_tracks( 66 + client: Client, 67 + paths: List(String), 68 + position: InsertPosition, 69 + playlist_id: Option(String), 70 + ) -> Result(Nil, Error) { 71 + let vars = 72 + transport.variables([ 73 + #("playlistId", option.map(playlist_id, json.string)), 74 + #("position", Some(json.int(types.insert_position_to_int(position)))), 75 + #( 76 + "tracks", 77 + Some(json.array(paths, json.string)), 78 + ), 79 + ]) 80 + rockbox.execute( 81 + client, 82 + "mutation InsertTracks($playlistId: String, $position: Int!, $tracks: [String!]!) { 83 + insertTracks(playlistId: $playlistId, position: $position, tracks: $tracks) 84 + }", 85 + vars, 86 + ) 87 + } 88 + 89 + /// Append every track in a directory (optionally recursive) to the queue. 90 + pub fn insert_directory( 91 + client: Client, 92 + directory: String, 93 + position: InsertPosition, 94 + playlist_id: Option(String), 95 + ) -> Result(Nil, Error) { 96 + let vars = 97 + transport.variables([ 98 + #("playlistId", option.map(playlist_id, json.string)), 99 + #("position", Some(json.int(types.insert_position_to_int(position)))), 100 + #("directory", Some(json.string(directory))), 101 + ]) 102 + rockbox.execute( 103 + client, 104 + "mutation InsertDirectory($playlistId: String, $position: Int!, $directory: String!) { 105 + insertDirectory(playlistId: $playlistId, position: $position, directory: $directory) 106 + }", 107 + vars, 108 + ) 109 + } 110 + 111 + /// Append every track on an album to the queue. 112 + pub fn insert_album( 113 + client: Client, 114 + album_id: String, 115 + position: InsertPosition, 116 + ) -> Result(Nil, Error) { 117 + rockbox.execute( 118 + client, 119 + "mutation InsertAlbum($albumId: String!, $position: Int!) { 120 + insertAlbum(albumId: $albumId, position: $position) 121 + }", 122 + json.object([ 123 + #("albumId", json.string(album_id)), 124 + #("position", json.int(types.insert_position_to_int(position))), 125 + ]), 126 + ) 127 + } 128 + 129 + pub fn remove_track(client: Client, index: Int) -> Result(Nil, Error) { 130 + rockbox.execute( 131 + client, 132 + "mutation RemoveTrack($index: Int!) { playlistRemoveTrack(index: $index) }", 133 + json.object([#("index", json.int(index))]), 134 + ) 135 + } 136 + 137 + /// Empty the active queue. 138 + pub fn clear(client: Client) -> Result(Nil, Error) { 139 + rockbox.execute( 140 + client, 141 + "mutation ClearPlaylist { playlistRemoveAllTracks }", 142 + json.object([]), 143 + ) 144 + } 145 + 146 + /// Shuffle the active queue in place. 147 + pub fn shuffle(client: Client) -> Result(Nil, Error) { 148 + rockbox.execute( 149 + client, 150 + "mutation ShufflePlaylist { shufflePlaylist }", 151 + json.object([]), 152 + ) 153 + } 154 + 155 + /// Replace the queue with a new ad-hoc playlist and start playing it. 156 + pub fn create( 157 + client: Client, 158 + name: String, 159 + tracks: List(String), 160 + ) -> Result(Nil, Error) { 161 + rockbox.execute( 162 + client, 163 + "mutation CreatePlaylist($name: String!, $tracks: [String!]!) { 164 + playlistCreate(name: $name, tracks: $tracks) 165 + }", 166 + json.object([ 167 + #("name", json.string(name)), 168 + #("tracks", json.array(tracks, json.string)), 169 + ]), 170 + ) 171 + } 172 + 173 + /// Optional knobs for `start`. 174 + pub opaque type StartOptions { 175 + StartOptions( 176 + start_index: Option(Int), 177 + elapsed: Option(Int), 178 + offset: Option(Int), 179 + ) 180 + } 181 + 182 + pub fn start_options() -> StartOptions { 183 + StartOptions(start_index: None, elapsed: None, offset: None) 184 + } 185 + 186 + pub fn at_index(opts: StartOptions, value: Int) -> StartOptions { 187 + StartOptions(..opts, start_index: Some(value)) 188 + } 189 + 190 + pub fn at_elapsed(opts: StartOptions, value: Int) -> StartOptions { 191 + StartOptions(..opts, elapsed: Some(value)) 192 + } 193 + 194 + pub fn at_offset(opts: StartOptions, value: Int) -> StartOptions { 195 + StartOptions(..opts, offset: Some(value)) 196 + } 197 + 198 + /// Start playing the active queue from the given position. 199 + pub fn start(client: Client, options: StartOptions) -> Result(Nil, Error) { 200 + let pairs = [ 201 + #("startIndex", option.map(options.start_index, json.int)), 202 + #("elapsed", option.map(options.elapsed, json.int)), 203 + #("offset", option.map(options.offset, json.int)), 204 + ] 205 + rockbox.execute( 206 + client, 207 + "mutation PlaylistStart($startIndex: Int, $elapsed: Int, $offset: Int) { 208 + playlistStart(startIndex: $startIndex, elapsed: $elapsed, offset: $offset) 209 + }", 210 + transport.variables(pairs), 211 + ) 212 + } 213 + 214 + /// Resume an interrupted queue from the saved position. 215 + pub fn resume(client: Client) -> Result(Nil, Error) { 216 + rockbox.execute( 217 + client, 218 + "mutation PlaylistResume { playlistResume }", 219 + json.object([]), 220 + ) 221 + } 222 +
+335
sdk/gleam/src/rockbox/saved_playlists.gleam
··· 1 + //// Persisted playlists and their folders. 2 + //// 3 + //// Builders make optional fields ergonomic: 4 + //// 5 + //// ```gleam 6 + //// let input = 7 + //// saved_playlists.new("Workout Mix") 8 + //// |> saved_playlists.with_description("Tracks I run to") 9 + //// |> saved_playlists.with_tracks(["track-id-1", "track-id-2"]) 10 + //// 11 + //// let assert Ok(playlist) = saved_playlists.create(client, input) 12 + //// ``` 13 + 14 + import gleam/dynamic/decode 15 + import gleam/json 16 + import gleam/option.{type Option, None, Some} 17 + import rockbox.{type Client} 18 + import rockbox/error.{type Error} 19 + import rockbox/internal/transport 20 + import rockbox/types.{type SavedPlaylist, type SavedPlaylistFolder} 21 + 22 + // --------------------------------------------------------------------------- 23 + // CreateInput builder 24 + // --------------------------------------------------------------------------- 25 + 26 + pub opaque type CreateInput { 27 + CreateInput( 28 + name: String, 29 + description: Option(String), 30 + image: Option(String), 31 + folder_id: Option(String), 32 + track_ids: Option(List(String)), 33 + ) 34 + } 35 + 36 + /// Start a new create-input with just a name. 37 + pub fn new(name: String) -> CreateInput { 38 + CreateInput( 39 + name: name, 40 + description: None, 41 + image: None, 42 + folder_id: None, 43 + track_ids: None, 44 + ) 45 + } 46 + 47 + pub fn with_description(input: CreateInput, value: String) -> CreateInput { 48 + CreateInput(..input, description: Some(value)) 49 + } 50 + 51 + pub fn with_image(input: CreateInput, value: String) -> CreateInput { 52 + CreateInput(..input, image: Some(value)) 53 + } 54 + 55 + pub fn with_folder(input: CreateInput, folder_id: String) -> CreateInput { 56 + CreateInput(..input, folder_id: Some(folder_id)) 57 + } 58 + 59 + pub fn with_tracks(input: CreateInput, track_ids: List(String)) -> CreateInput { 60 + CreateInput(..input, track_ids: Some(track_ids)) 61 + } 62 + 63 + // --------------------------------------------------------------------------- 64 + // UpdateInput builder 65 + // --------------------------------------------------------------------------- 66 + 67 + pub opaque type UpdateInput { 68 + UpdateInput( 69 + name: String, 70 + description: Option(String), 71 + image: Option(String), 72 + folder_id: Option(String), 73 + ) 74 + } 75 + 76 + /// Build an update payload — `name` is required by the GraphQL schema even 77 + /// when only changing other fields, so it lives in the constructor. 78 + pub fn update(name: String) -> UpdateInput { 79 + UpdateInput(name: name, description: None, image: None, folder_id: None) 80 + } 81 + 82 + pub fn update_description(input: UpdateInput, value: String) -> UpdateInput { 83 + UpdateInput(..input, description: Some(value)) 84 + } 85 + 86 + pub fn update_image(input: UpdateInput, value: String) -> UpdateInput { 87 + UpdateInput(..input, image: Some(value)) 88 + } 89 + 90 + pub fn update_folder(input: UpdateInput, folder_id: String) -> UpdateInput { 91 + UpdateInput(..input, folder_id: Some(folder_id)) 92 + } 93 + 94 + // --------------------------------------------------------------------------- 95 + // Queries 96 + // --------------------------------------------------------------------------- 97 + 98 + /// List all saved playlists, optionally scoped to a single folder. 99 + pub fn list( 100 + client: Client, 101 + folder_id: Option(String), 102 + ) -> Result(List(SavedPlaylist), Error) { 103 + let decoder = { 104 + use playlists <- decode.field( 105 + "savedPlaylists", 106 + decode.list(types.saved_playlist_decoder()), 107 + ) 108 + decode.success(playlists) 109 + } 110 + let vars = 111 + transport.variables([#("folderId", option.map(folder_id, json.string))]) 112 + rockbox.query( 113 + client, 114 + "query SavedPlaylists($folderId: String) { 115 + savedPlaylists(folderId: $folderId) { 116 + id name description image folderId trackCount createdAt updatedAt 117 + } 118 + }", 119 + vars, 120 + decoder, 121 + ) 122 + } 123 + 124 + pub fn get( 125 + client: Client, 126 + id: String, 127 + ) -> Result(Option(SavedPlaylist), Error) { 128 + let decoder = { 129 + use playlist <- decode.field( 130 + "savedPlaylist", 131 + decode.optional(types.saved_playlist_decoder()), 132 + ) 133 + decode.success(playlist) 134 + } 135 + rockbox.query( 136 + client, 137 + "query SavedPlaylist($id: String!) { 138 + savedPlaylist(id: $id) { 139 + id name description image folderId trackCount createdAt updatedAt 140 + } 141 + }", 142 + json.object([#("id", json.string(id))]), 143 + decoder, 144 + ) 145 + } 146 + 147 + pub fn track_ids( 148 + client: Client, 149 + playlist_id: String, 150 + ) -> Result(List(String), Error) { 151 + let decoder = { 152 + use ids <- decode.field( 153 + "savedPlaylistTrackIds", 154 + decode.list(decode.string), 155 + ) 156 + decode.success(ids) 157 + } 158 + rockbox.query( 159 + client, 160 + "query SavedPlaylistTrackIds($playlistId: String!) { 161 + savedPlaylistTrackIds(playlistId: $playlistId) 162 + }", 163 + json.object([#("playlistId", json.string(playlist_id))]), 164 + decoder, 165 + ) 166 + } 167 + 168 + // --------------------------------------------------------------------------- 169 + // Mutations 170 + // --------------------------------------------------------------------------- 171 + 172 + pub fn create( 173 + client: Client, 174 + input: CreateInput, 175 + ) -> Result(SavedPlaylist, Error) { 176 + let vars = 177 + transport.variables([ 178 + #("name", Some(json.string(input.name))), 179 + #("description", option.map(input.description, json.string)), 180 + #("image", option.map(input.image, json.string)), 181 + #("folderId", option.map(input.folder_id, json.string)), 182 + #( 183 + "trackIds", 184 + option.map(input.track_ids, fn(ids) { json.array(ids, json.string) }), 185 + ), 186 + ]) 187 + let decoder = { 188 + use playlist <- decode.field( 189 + "createSavedPlaylist", 190 + types.saved_playlist_decoder(), 191 + ) 192 + decode.success(playlist) 193 + } 194 + rockbox.query( 195 + client, 196 + "mutation CreateSavedPlaylist( 197 + $name: String!, $description: String, $image: String, 198 + $folderId: String, $trackIds: [String!] 199 + ) { 200 + createSavedPlaylist( 201 + name: $name, description: $description, image: $image, 202 + folderId: $folderId, trackIds: $trackIds 203 + ) { 204 + id name description image folderId trackCount createdAt updatedAt 205 + } 206 + }", 207 + vars, 208 + decoder, 209 + ) 210 + } 211 + 212 + pub fn save( 213 + client: Client, 214 + id: String, 215 + input: UpdateInput, 216 + ) -> Result(Nil, Error) { 217 + let vars = 218 + transport.variables([ 219 + #("id", Some(json.string(id))), 220 + #("name", Some(json.string(input.name))), 221 + #("description", option.map(input.description, json.string)), 222 + #("image", option.map(input.image, json.string)), 223 + #("folderId", option.map(input.folder_id, json.string)), 224 + ]) 225 + rockbox.execute( 226 + client, 227 + "mutation UpdateSavedPlaylist( 228 + $id: String!, $name: String!, $description: String, $image: String, $folderId: String 229 + ) { 230 + updateSavedPlaylist( 231 + id: $id, name: $name, description: $description, image: $image, folderId: $folderId 232 + ) 233 + }", 234 + vars, 235 + ) 236 + } 237 + 238 + pub fn delete(client: Client, id: String) -> Result(Nil, Error) { 239 + rockbox.execute( 240 + client, 241 + "mutation DeleteSavedPlaylist($id: String!) { deleteSavedPlaylist(id: $id) }", 242 + json.object([#("id", json.string(id))]), 243 + ) 244 + } 245 + 246 + pub fn add_tracks( 247 + client: Client, 248 + playlist_id: String, 249 + track_ids: List(String), 250 + ) -> Result(Nil, Error) { 251 + rockbox.execute( 252 + client, 253 + "mutation AddTracksToSavedPlaylist($playlistId: String!, $trackIds: [String!]!) { 254 + addTracksToSavedPlaylist(playlistId: $playlistId, trackIds: $trackIds) 255 + }", 256 + json.object([ 257 + #("playlistId", json.string(playlist_id)), 258 + #("trackIds", json.array(track_ids, json.string)), 259 + ]), 260 + ) 261 + } 262 + 263 + pub fn remove_track( 264 + client: Client, 265 + playlist_id: String, 266 + track_id: String, 267 + ) -> Result(Nil, Error) { 268 + rockbox.execute( 269 + client, 270 + "mutation RemoveTrackFromSavedPlaylist($playlistId: String!, $trackId: String!) { 271 + removeTrackFromSavedPlaylist(playlistId: $playlistId, trackId: $trackId) 272 + }", 273 + json.object([ 274 + #("playlistId", json.string(playlist_id)), 275 + #("trackId", json.string(track_id)), 276 + ]), 277 + ) 278 + } 279 + 280 + pub fn play(client: Client, playlist_id: String) -> Result(Nil, Error) { 281 + rockbox.execute( 282 + client, 283 + "mutation PlaySavedPlaylist($playlistId: String!) { playSavedPlaylist(playlistId: $playlistId) }", 284 + json.object([#("playlistId", json.string(playlist_id))]), 285 + ) 286 + } 287 + 288 + // --------------------------------------------------------------------------- 289 + // Folders 290 + // --------------------------------------------------------------------------- 291 + 292 + pub fn folders(client: Client) -> Result(List(SavedPlaylistFolder), Error) { 293 + let decoder = { 294 + use folders <- decode.field( 295 + "playlistFolders", 296 + decode.list(types.saved_playlist_folder_decoder()), 297 + ) 298 + decode.success(folders) 299 + } 300 + rockbox.query( 301 + client, 302 + "query PlaylistFolders { playlistFolders { id name createdAt updatedAt } }", 303 + json.object([]), 304 + decoder, 305 + ) 306 + } 307 + 308 + pub fn create_folder( 309 + client: Client, 310 + name: String, 311 + ) -> Result(SavedPlaylistFolder, Error) { 312 + let decoder = { 313 + use folder <- decode.field( 314 + "createPlaylistFolder", 315 + types.saved_playlist_folder_decoder(), 316 + ) 317 + decode.success(folder) 318 + } 319 + rockbox.query( 320 + client, 321 + "mutation CreatePlaylistFolder($name: String!) { 322 + createPlaylistFolder(name: $name) { id name createdAt updatedAt } 323 + }", 324 + json.object([#("name", json.string(name))]), 325 + decoder, 326 + ) 327 + } 328 + 329 + pub fn delete_folder(client: Client, id: String) -> Result(Nil, Error) { 330 + rockbox.execute( 331 + client, 332 + "mutation DeletePlaylistFolder($id: String!) { deletePlaylistFolder(id: $id) }", 333 + json.object([#("id", json.string(id))]), 334 + ) 335 + }
+215
sdk/gleam/src/rockbox/settings.gleam
··· 1 + //// Read and patch the global daemon settings (volume, EQ, crossfeed, …). 2 + //// 3 + //// Use the `update_*` builders to construct a partial update — only the 4 + //// fields you set are sent to the server: 5 + //// 6 + //// ```gleam 7 + //// let patch = 8 + //// settings.patch() 9 + //// |> settings.set_volume(-20) 10 + //// |> settings.set_shuffle(True) 11 + //// 12 + //// let assert Ok(_) = settings.save(client, patch) 13 + //// ``` 14 + 15 + import gleam/dynamic/decode 16 + import gleam/json.{type Json} 17 + import gleam/list 18 + import rockbox.{type Client} 19 + import rockbox/error.{type Error} 20 + import rockbox/types.{ 21 + type CompressorSettings, type EqBandSetting, type ReplaygainSettings, 22 + type UserSettings, 23 + } 24 + 25 + /// Read every settings field. 26 + pub fn get(client: Client) -> Result(UserSettings, Error) { 27 + let decoder = { 28 + use settings <- decode.field("globalSettings", types.user_settings_decoder()) 29 + decode.success(settings) 30 + } 31 + rockbox.query( 32 + client, 33 + "query GlobalSettings { 34 + globalSettings { 35 + musicDir volume balance bass treble channelConfig stereoWidth 36 + eqEnabled eqPrecut 37 + eqBandSettings { cutoff q gain } 38 + replaygainSettings { noclip type preamp } 39 + compressorSettings { threshold makeupGain ratio knee releaseTime attackTime } 40 + crossfadeEnabled crossfadeFadeInDelay crossfadeFadeInDuration 41 + crossfadeFadeOutDelay crossfadeFadeOutDuration crossfadeFadeOutMixmode 42 + crossfeedEnabled crossfeedDirectGain crossfeedCrossGain 43 + crossfeedHfAttenuation crossfeedHfCutoff 44 + repeatMode singleMode partyMode shuffle playerName 45 + } 46 + }", 47 + json.object([]), 48 + decoder, 49 + ) 50 + } 51 + 52 + // --------------------------------------------------------------------------- 53 + // Partial-update builder 54 + // --------------------------------------------------------------------------- 55 + 56 + pub opaque type Patch { 57 + Patch(fields: List(#(String, Json))) 58 + } 59 + 60 + /// Empty patch — chain `set_*` to populate. 61 + pub fn patch() -> Patch { 62 + Patch(fields: []) 63 + } 64 + 65 + fn set(patch: Patch, key: String, value: Json) -> Patch { 66 + Patch(fields: [#(key, value), ..patch.fields]) 67 + } 68 + 69 + pub fn set_music_dir(patch: Patch, value: String) -> Patch { 70 + set(patch, "musicDir", json.string(value)) 71 + } 72 + 73 + pub fn set_volume(patch: Patch, value: Int) -> Patch { 74 + set(patch, "volume", json.int(value)) 75 + } 76 + 77 + pub fn set_balance(patch: Patch, value: Int) -> Patch { 78 + set(patch, "balance", json.int(value)) 79 + } 80 + 81 + pub fn set_bass(patch: Patch, value: Int) -> Patch { 82 + set(patch, "bass", json.int(value)) 83 + } 84 + 85 + pub fn set_treble(patch: Patch, value: Int) -> Patch { 86 + set(patch, "treble", json.int(value)) 87 + } 88 + 89 + pub fn set_channel_config(patch: Patch, value: Int) -> Patch { 90 + set(patch, "channelConfig", json.int(value)) 91 + } 92 + 93 + pub fn set_stereo_width(patch: Patch, value: Int) -> Patch { 94 + set(patch, "stereoWidth", json.int(value)) 95 + } 96 + 97 + pub fn set_eq_enabled(patch: Patch, value: Bool) -> Patch { 98 + set(patch, "eqEnabled", json.bool(value)) 99 + } 100 + 101 + pub fn set_eq_precut(patch: Patch, value: Int) -> Patch { 102 + set(patch, "eqPrecut", json.int(value)) 103 + } 104 + 105 + pub fn set_eq_bands(patch: Patch, value: List(EqBandSetting)) -> Patch { 106 + let encoder = fn(band: EqBandSetting) { 107 + json.object([ 108 + #("cutoff", json.int(band.cutoff)), 109 + #("q", json.int(band.q)), 110 + #("gain", json.int(band.gain)), 111 + ]) 112 + } 113 + set(patch, "eqBandSettings", json.array(value, encoder)) 114 + } 115 + 116 + pub fn set_replaygain(patch: Patch, value: ReplaygainSettings) -> Patch { 117 + set( 118 + patch, 119 + "replaygainSettings", 120 + json.object([ 121 + #("noclip", json.bool(value.noclip)), 122 + #("type", json.int(value.type_)), 123 + #("preamp", json.int(value.preamp)), 124 + ]), 125 + ) 126 + } 127 + 128 + pub fn set_compressor(patch: Patch, value: CompressorSettings) -> Patch { 129 + set( 130 + patch, 131 + "compressorSettings", 132 + json.object([ 133 + #("threshold", json.int(value.threshold)), 134 + #("makeupGain", json.int(value.makeup_gain)), 135 + #("ratio", json.int(value.ratio)), 136 + #("knee", json.int(value.knee)), 137 + #("releaseTime", json.int(value.release_time)), 138 + #("attackTime", json.int(value.attack_time)), 139 + ]), 140 + ) 141 + } 142 + 143 + pub fn set_crossfade_enabled(patch: Patch, value: Int) -> Patch { 144 + set(patch, "crossfadeEnabled", json.int(value)) 145 + } 146 + 147 + pub fn set_crossfade_fade_in_delay(patch: Patch, value: Int) -> Patch { 148 + set(patch, "crossfadeFadeInDelay", json.int(value)) 149 + } 150 + 151 + pub fn set_crossfade_fade_in_duration(patch: Patch, value: Int) -> Patch { 152 + set(patch, "crossfadeFadeInDuration", json.int(value)) 153 + } 154 + 155 + pub fn set_crossfade_fade_out_delay(patch: Patch, value: Int) -> Patch { 156 + set(patch, "crossfadeFadeOutDelay", json.int(value)) 157 + } 158 + 159 + pub fn set_crossfade_fade_out_duration(patch: Patch, value: Int) -> Patch { 160 + set(patch, "crossfadeFadeOutDuration", json.int(value)) 161 + } 162 + 163 + pub fn set_crossfade_fade_out_mixmode(patch: Patch, value: Int) -> Patch { 164 + set(patch, "crossfadeFadeOutMixmode", json.int(value)) 165 + } 166 + 167 + pub fn set_crossfeed_enabled(patch: Patch, value: Bool) -> Patch { 168 + set(patch, "crossfeedEnabled", json.bool(value)) 169 + } 170 + 171 + pub fn set_crossfeed_direct_gain(patch: Patch, value: Int) -> Patch { 172 + set(patch, "crossfeedDirectGain", json.int(value)) 173 + } 174 + 175 + pub fn set_crossfeed_cross_gain(patch: Patch, value: Int) -> Patch { 176 + set(patch, "crossfeedCrossGain", json.int(value)) 177 + } 178 + 179 + pub fn set_crossfeed_hf_attenuation(patch: Patch, value: Int) -> Patch { 180 + set(patch, "crossfeedHfAttenuation", json.int(value)) 181 + } 182 + 183 + pub fn set_crossfeed_hf_cutoff(patch: Patch, value: Int) -> Patch { 184 + set(patch, "crossfeedHfCutoff", json.int(value)) 185 + } 186 + 187 + pub fn set_repeat_mode(patch: Patch, value: Int) -> Patch { 188 + set(patch, "repeatMode", json.int(value)) 189 + } 190 + 191 + pub fn set_single_mode(patch: Patch, value: Bool) -> Patch { 192 + set(patch, "singleMode", json.bool(value)) 193 + } 194 + 195 + pub fn set_party_mode(patch: Patch, value: Bool) -> Patch { 196 + set(patch, "partyMode", json.bool(value)) 197 + } 198 + 199 + pub fn set_shuffle(patch: Patch, value: Bool) -> Patch { 200 + set(patch, "shuffle", json.bool(value)) 201 + } 202 + 203 + pub fn set_player_name(patch: Patch, value: String) -> Patch { 204 + set(patch, "playerName", json.string(value)) 205 + } 206 + 207 + /// Push the patch to the daemon. 208 + pub fn save(client: Client, patch: Patch) -> Result(Nil, Error) { 209 + let payload = json.object(list.reverse(patch.fields)) 210 + rockbox.execute( 211 + client, 212 + "mutation SaveSettings($settings: NewGlobalSettings!) { saveSettings(settings: $settings) }", 213 + json.object([#("settings", payload)]), 214 + ) 215 + }
+265
sdk/gleam/src/rockbox/smart_playlists.gleam
··· 1 + //// Rule-based "smart" playlists that auto-update from listening stats. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import gleam/option.{type Option, None, Some} 6 + import rockbox.{type Client} 7 + import rockbox/error.{type Error} 8 + import rockbox/internal/transport 9 + import rockbox/types.{type SmartPlaylist, type TrackStats} 10 + 11 + // --------------------------------------------------------------------------- 12 + // Input builders 13 + // --------------------------------------------------------------------------- 14 + 15 + pub opaque type CreateInput { 16 + CreateInput( 17 + name: String, 18 + rules: String, 19 + description: Option(String), 20 + image: Option(String), 21 + folder_id: Option(String), 22 + ) 23 + } 24 + 25 + /// `rules` is the JSON-encoded smart-playlist rule set. The format matches 26 + /// the rockboxd schema — see the project README for details. 27 + pub fn new(name: String, rules: String) -> CreateInput { 28 + CreateInput( 29 + name: name, 30 + rules: rules, 31 + description: None, 32 + image: None, 33 + folder_id: None, 34 + ) 35 + } 36 + 37 + pub fn with_description(input: CreateInput, value: String) -> CreateInput { 38 + CreateInput(..input, description: Some(value)) 39 + } 40 + 41 + pub fn with_image(input: CreateInput, value: String) -> CreateInput { 42 + CreateInput(..input, image: Some(value)) 43 + } 44 + 45 + pub fn with_folder(input: CreateInput, folder_id: String) -> CreateInput { 46 + CreateInput(..input, folder_id: Some(folder_id)) 47 + } 48 + 49 + pub opaque type UpdateInput { 50 + UpdateInput( 51 + name: String, 52 + rules: String, 53 + description: Option(String), 54 + image: Option(String), 55 + folder_id: Option(String), 56 + ) 57 + } 58 + 59 + pub fn update(name: String, rules: String) -> UpdateInput { 60 + UpdateInput( 61 + name: name, 62 + rules: rules, 63 + description: None, 64 + image: None, 65 + folder_id: None, 66 + ) 67 + } 68 + 69 + pub fn update_description(input: UpdateInput, value: String) -> UpdateInput { 70 + UpdateInput(..input, description: Some(value)) 71 + } 72 + 73 + pub fn update_image(input: UpdateInput, value: String) -> UpdateInput { 74 + UpdateInput(..input, image: Some(value)) 75 + } 76 + 77 + pub fn update_folder(input: UpdateInput, folder_id: String) -> UpdateInput { 78 + UpdateInput(..input, folder_id: Some(folder_id)) 79 + } 80 + 81 + // --------------------------------------------------------------------------- 82 + // Queries 83 + // --------------------------------------------------------------------------- 84 + 85 + pub fn list(client: Client) -> Result(List(SmartPlaylist), Error) { 86 + let decoder = { 87 + use playlists <- decode.field( 88 + "smartPlaylists", 89 + decode.list(types.smart_playlist_decoder()), 90 + ) 91 + decode.success(playlists) 92 + } 93 + rockbox.query( 94 + client, 95 + "query SmartPlaylists { 96 + smartPlaylists { 97 + id name description image folderId isSystem rules createdAt updatedAt 98 + } 99 + }", 100 + json.object([]), 101 + decoder, 102 + ) 103 + } 104 + 105 + pub fn get(client: Client, id: String) -> Result(Option(SmartPlaylist), Error) { 106 + let decoder = { 107 + use playlist <- decode.field( 108 + "smartPlaylist", 109 + decode.optional(types.smart_playlist_decoder()), 110 + ) 111 + decode.success(playlist) 112 + } 113 + rockbox.query( 114 + client, 115 + "query SmartPlaylist($id: String!) { 116 + smartPlaylist(id: $id) { 117 + id name description image folderId isSystem rules createdAt updatedAt 118 + } 119 + }", 120 + json.object([#("id", json.string(id))]), 121 + decoder, 122 + ) 123 + } 124 + 125 + pub fn track_ids(client: Client, id: String) -> Result(List(String), Error) { 126 + let decoder = { 127 + use ids <- decode.field("smartPlaylistTrackIds", decode.list(decode.string)) 128 + decode.success(ids) 129 + } 130 + rockbox.query( 131 + client, 132 + "query SmartPlaylistTrackIds($id: String!) { smartPlaylistTrackIds(id: $id) }", 133 + json.object([#("id", json.string(id))]), 134 + decoder, 135 + ) 136 + } 137 + 138 + // --------------------------------------------------------------------------- 139 + // Mutations 140 + // --------------------------------------------------------------------------- 141 + 142 + pub fn create( 143 + client: Client, 144 + input: CreateInput, 145 + ) -> Result(SmartPlaylist, Error) { 146 + let vars = 147 + transport.variables([ 148 + #("name", Some(json.string(input.name))), 149 + #("rules", Some(json.string(input.rules))), 150 + #("description", option.map(input.description, json.string)), 151 + #("image", option.map(input.image, json.string)), 152 + #("folderId", option.map(input.folder_id, json.string)), 153 + ]) 154 + let decoder = { 155 + use playlist <- decode.field( 156 + "createSmartPlaylist", 157 + types.smart_playlist_decoder(), 158 + ) 159 + decode.success(playlist) 160 + } 161 + rockbox.query( 162 + client, 163 + "mutation CreateSmartPlaylist( 164 + $name: String!, $rules: String!, $description: String, 165 + $image: String, $folderId: String 166 + ) { 167 + createSmartPlaylist( 168 + name: $name, rules: $rules, description: $description, 169 + image: $image, folderId: $folderId 170 + ) { 171 + id name description image folderId isSystem rules createdAt updatedAt 172 + } 173 + }", 174 + vars, 175 + decoder, 176 + ) 177 + } 178 + 179 + pub fn save( 180 + client: Client, 181 + id: String, 182 + input: UpdateInput, 183 + ) -> Result(Nil, Error) { 184 + let vars = 185 + transport.variables([ 186 + #("id", Some(json.string(id))), 187 + #("name", Some(json.string(input.name))), 188 + #("rules", Some(json.string(input.rules))), 189 + #("description", option.map(input.description, json.string)), 190 + #("image", option.map(input.image, json.string)), 191 + #("folderId", option.map(input.folder_id, json.string)), 192 + ]) 193 + rockbox.execute( 194 + client, 195 + "mutation UpdateSmartPlaylist( 196 + $id: String!, $name: String!, $rules: String!, 197 + $description: String, $image: String, $folderId: String 198 + ) { 199 + updateSmartPlaylist( 200 + id: $id, name: $name, rules: $rules, description: $description, 201 + image: $image, folderId: $folderId 202 + ) 203 + }", 204 + vars, 205 + ) 206 + } 207 + 208 + pub fn delete(client: Client, id: String) -> Result(Nil, Error) { 209 + rockbox.execute( 210 + client, 211 + "mutation DeleteSmartPlaylist($id: String!) { deleteSmartPlaylist(id: $id) }", 212 + json.object([#("id", json.string(id))]), 213 + ) 214 + } 215 + 216 + pub fn play(client: Client, id: String) -> Result(Nil, Error) { 217 + rockbox.execute( 218 + client, 219 + "mutation PlaySmartPlaylist($id: String!) { playSmartPlaylist(id: $id) }", 220 + json.object([#("id", json.string(id))]), 221 + ) 222 + } 223 + 224 + // --------------------------------------------------------------------------- 225 + // Listening stats — feed smart playlist rules 226 + // --------------------------------------------------------------------------- 227 + 228 + pub fn track_stats( 229 + client: Client, 230 + track_id: String, 231 + ) -> Result(Option(TrackStats), Error) { 232 + let decoder = { 233 + use stats <- decode.field( 234 + "trackStats", 235 + decode.optional(types.track_stats_decoder()), 236 + ) 237 + decode.success(stats) 238 + } 239 + rockbox.query( 240 + client, 241 + "query TrackStats($trackId: String!) { 242 + trackStats(trackId: $trackId) { 243 + trackId playCount skipCount lastPlayed lastSkipped updatedAt 244 + } 245 + }", 246 + json.object([#("trackId", json.string(track_id))]), 247 + decoder, 248 + ) 249 + } 250 + 251 + pub fn record_played(client: Client, track_id: String) -> Result(Nil, Error) { 252 + rockbox.execute( 253 + client, 254 + "mutation RecordTrackPlayed($trackId: String!) { recordTrackPlayed(trackId: $trackId) }", 255 + json.object([#("trackId", json.string(track_id))]), 256 + ) 257 + } 258 + 259 + pub fn record_skipped(client: Client, track_id: String) -> Result(Nil, Error) { 260 + rockbox.execute( 261 + client, 262 + "mutation RecordTrackSkipped($trackId: String!) { recordTrackSkipped(trackId: $trackId) }", 263 + json.object([#("trackId", json.string(track_id))]), 264 + ) 265 + }
+46
sdk/gleam/src/rockbox/sound.gleam
··· 1 + //// Volume control. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import rockbox.{type Client} 6 + import rockbox/error.{type Error} 7 + import rockbox/types.{type VolumeInfo} 8 + 9 + /// Current volume with the firmware-reported `min` / `max` range. 10 + pub fn get_volume(client: Client) -> Result(VolumeInfo, Error) { 11 + let decoder = { 12 + use volume <- decode.field("volume", types.volume_info_decoder()) 13 + decode.success(volume) 14 + } 15 + rockbox.query( 16 + client, 17 + "query Volume { volume { volume min max } }", 18 + json.object([]), 19 + decoder, 20 + ) 21 + } 22 + 23 + /// Adjust volume by a relative number of steps. Returns the new absolute 24 + /// volume. 25 + pub fn adjust_volume(client: Client, steps: Int) -> Result(Int, Error) { 26 + let decoder = { 27 + use volume <- decode.field("adjustVolume", decode.int) 28 + decode.success(volume) 29 + } 30 + rockbox.query( 31 + client, 32 + "mutation AdjustVolume($steps: Int!) { adjustVolume(steps: $steps) }", 33 + json.object([#("steps", json.int(steps))]), 34 + decoder, 35 + ) 36 + } 37 + 38 + /// Bump the volume up by one step. 39 + pub fn volume_up(client: Client) -> Result(Int, Error) { 40 + adjust_volume(client, 1) 41 + } 42 + 43 + /// Drop the volume down by one step. 44 + pub fn volume_down(client: Client) -> Result(Int, Error) { 45 + adjust_volume(client, -1) 46 + }
+39
sdk/gleam/src/rockbox/system.gleam
··· 1 + //// Daemon information — version string and global runtime status. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import rockbox.{type Client} 6 + import rockbox/error.{type Error} 7 + import rockbox/types.{type SystemStatus} 8 + 9 + pub fn version(client: Client) -> Result(String, Error) { 10 + let decoder = { 11 + use v <- decode.field("rockboxVersion", decode.string) 12 + decode.success(v) 13 + } 14 + rockbox.query( 15 + client, 16 + "query Version { rockboxVersion }", 17 + json.object([]), 18 + decoder, 19 + ) 20 + } 21 + 22 + pub fn status(client: Client) -> Result(SystemStatus, Error) { 23 + let decoder = { 24 + use status <- decode.field("globalStatus", types.system_status_decoder()) 25 + decode.success(status) 26 + } 27 + rockbox.query( 28 + client, 29 + "query GlobalStatus { 30 + globalStatus { 31 + resumeIndex resumeCrc32 resumeElapsed resumeOffset 32 + runtime topruntime dircacheSize 33 + lastScreen viewerIconCount lastVolumeChange 34 + } 35 + }", 36 + json.object([]), 37 + decoder, 38 + ) 39 + }
+846
sdk/gleam/src/rockbox/types.gleam
··· 1 + //// Domain types and JSON decoders shared across the SDK. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/option.{type Option, None} 5 + 6 + // --------------------------------------------------------------------------- 7 + // Enums 8 + // --------------------------------------------------------------------------- 9 + 10 + pub type PlaybackStatus { 11 + Stopped 12 + Playing 13 + Paused 14 + UnknownStatus(Int) 15 + } 16 + 17 + pub fn playback_status_from_int(value: Int) -> PlaybackStatus { 18 + case value { 19 + 0 -> Stopped 20 + 1 -> Playing 21 + 3 -> Paused 22 + other -> UnknownStatus(other) 23 + } 24 + } 25 + 26 + pub fn playback_status_to_int(status: PlaybackStatus) -> Int { 27 + case status { 28 + Stopped -> 0 29 + Playing -> 1 30 + Paused -> 3 31 + UnknownStatus(n) -> n 32 + } 33 + } 34 + 35 + pub type RepeatMode { 36 + RepeatOff 37 + RepeatAll 38 + RepeatOne 39 + RepeatShuffle 40 + RepeatAB 41 + } 42 + 43 + pub fn repeat_mode_to_int(mode: RepeatMode) -> Int { 44 + case mode { 45 + RepeatOff -> 0 46 + RepeatAll -> 1 47 + RepeatOne -> 2 48 + RepeatShuffle -> 3 49 + RepeatAB -> 4 50 + } 51 + } 52 + 53 + pub type ChannelConfig { 54 + Stereo 55 + StereoNarrow 56 + Mono 57 + LeftMix 58 + RightMix 59 + Karaoke 60 + } 61 + 62 + pub fn channel_config_to_int(config: ChannelConfig) -> Int { 63 + case config { 64 + Stereo -> 0 65 + StereoNarrow -> 1 66 + Mono -> 2 67 + LeftMix -> 3 68 + RightMix -> 4 69 + Karaoke -> 5 70 + } 71 + } 72 + 73 + pub type ReplaygainType { 74 + ReplaygainTrack 75 + ReplaygainAlbum 76 + ReplaygainShuffle 77 + } 78 + 79 + /// Where new tracks land in the queue (matches Mopidy/Kodi conventions). 80 + pub type InsertPosition { 81 + /// Right after the currently playing track. 82 + Next 83 + /// After the last manually inserted track. 84 + AfterCurrent 85 + /// At the very end of the queue. 86 + Last 87 + /// Replace the entire playlist. 88 + First 89 + } 90 + 91 + pub fn insert_position_to_int(position: InsertPosition) -> Int { 92 + case position { 93 + Next -> 0 94 + AfterCurrent -> 1 95 + Last -> 2 96 + First -> 3 97 + } 98 + } 99 + 100 + // --------------------------------------------------------------------------- 101 + // Core audio types 102 + // --------------------------------------------------------------------------- 103 + 104 + pub type Track { 105 + Track( 106 + id: Option(String), 107 + title: String, 108 + artist: String, 109 + album: String, 110 + genre: String, 111 + disc: String, 112 + track_string: String, 113 + year_string: String, 114 + composer: String, 115 + comment: String, 116 + album_artist: String, 117 + grouping: String, 118 + discnum: Int, 119 + tracknum: Int, 120 + layer: Int, 121 + year: Int, 122 + bitrate: Int, 123 + frequency: Int, 124 + filesize: Int, 125 + /// Duration in milliseconds. 126 + length: Int, 127 + /// Current playback position in milliseconds. 128 + elapsed: Int, 129 + path: String, 130 + album_id: Option(String), 131 + artist_id: Option(String), 132 + genre_id: Option(String), 133 + album_art: Option(String), 134 + ) 135 + } 136 + 137 + pub type Album { 138 + Album( 139 + id: String, 140 + title: String, 141 + artist: String, 142 + year: Int, 143 + year_string: String, 144 + album_art: Option(String), 145 + md5: String, 146 + artist_id: String, 147 + copyright_message: Option(String), 148 + tracks: List(Track), 149 + ) 150 + } 151 + 152 + pub type Artist { 153 + Artist( 154 + id: String, 155 + name: String, 156 + bio: Option(String), 157 + image: Option(String), 158 + tracks: List(Track), 159 + albums: List(Album), 160 + ) 161 + } 162 + 163 + pub type SearchResults { 164 + SearchResults( 165 + artists: List(Artist), 166 + albums: List(Album), 167 + tracks: List(Track), 168 + liked_tracks: List(Track), 169 + liked_albums: List(Album), 170 + ) 171 + } 172 + 173 + // --------------------------------------------------------------------------- 174 + // Playlist types 175 + // --------------------------------------------------------------------------- 176 + 177 + pub type Playlist { 178 + Playlist( 179 + amount: Int, 180 + index: Int, 181 + max_playlist_size: Int, 182 + first_index: Int, 183 + last_insert_pos: Int, 184 + seed: Int, 185 + last_shuffled_start: Int, 186 + tracks: List(Track), 187 + ) 188 + } 189 + 190 + pub type SavedPlaylist { 191 + SavedPlaylist( 192 + id: String, 193 + name: String, 194 + description: Option(String), 195 + image: Option(String), 196 + folder_id: Option(String), 197 + track_count: Int, 198 + created_at: Int, 199 + updated_at: Int, 200 + ) 201 + } 202 + 203 + pub type SavedPlaylistFolder { 204 + SavedPlaylistFolder( 205 + id: String, 206 + name: String, 207 + created_at: Int, 208 + updated_at: Int, 209 + ) 210 + } 211 + 212 + pub type SmartPlaylist { 213 + SmartPlaylist( 214 + id: String, 215 + name: String, 216 + description: Option(String), 217 + image: Option(String), 218 + folder_id: Option(String), 219 + is_system: Bool, 220 + /// JSON-encoded rules string. 221 + rules: String, 222 + created_at: Int, 223 + updated_at: Int, 224 + ) 225 + } 226 + 227 + pub type TrackStats { 228 + TrackStats( 229 + track_id: String, 230 + play_count: Int, 231 + skip_count: Int, 232 + last_played: Option(Int), 233 + last_skipped: Option(Int), 234 + updated_at: Int, 235 + ) 236 + } 237 + 238 + // --------------------------------------------------------------------------- 239 + // Bluetooth / Sound / Devices / Browse / System / Settings 240 + // --------------------------------------------------------------------------- 241 + 242 + pub type BluetoothDevice { 243 + BluetoothDevice( 244 + address: String, 245 + name: String, 246 + paired: Bool, 247 + trusted: Bool, 248 + connected: Bool, 249 + rssi: Option(Int), 250 + ) 251 + } 252 + 253 + pub type VolumeInfo { 254 + VolumeInfo(volume: Int, min: Int, max: Int) 255 + } 256 + 257 + pub type Device { 258 + Device( 259 + id: String, 260 + name: String, 261 + host: String, 262 + ip: String, 263 + port: Int, 264 + service: String, 265 + app: String, 266 + is_connected: Bool, 267 + base_url: Option(String), 268 + is_cast_device: Bool, 269 + is_source_device: Bool, 270 + is_current_device: Bool, 271 + ) 272 + } 273 + 274 + pub type Entry { 275 + Entry( 276 + name: String, 277 + /// Bitmask: bit 4 (`0x10`) marks a directory. 278 + attr: Int, 279 + time_write: Int, 280 + customaction: Int, 281 + /// Human-readable display name (used for UPnP entries). 282 + display_name: Option(String), 283 + ) 284 + } 285 + 286 + /// True when a directory bit (0x10) is set on the entry's attribute mask. 287 + pub fn is_directory(entry: Entry) -> Bool { 288 + entry.attr / 16 % 2 == 1 289 + } 290 + 291 + pub type SystemStatus { 292 + SystemStatus( 293 + resume_index: Int, 294 + resume_crc32: Int, 295 + resume_elapsed: Int, 296 + resume_offset: Int, 297 + runtime: Int, 298 + topruntime: Int, 299 + dircache_size: Int, 300 + last_screen: Int, 301 + viewer_icon_count: Int, 302 + last_volume_change: Int, 303 + ) 304 + } 305 + 306 + pub type EqBandSetting { 307 + EqBandSetting(cutoff: Int, q: Int, gain: Int) 308 + } 309 + 310 + pub type ReplaygainSettings { 311 + ReplaygainSettings(noclip: Bool, type_: Int, preamp: Int) 312 + } 313 + 314 + pub type CompressorSettings { 315 + CompressorSettings( 316 + threshold: Int, 317 + makeup_gain: Int, 318 + ratio: Int, 319 + knee: Int, 320 + release_time: Int, 321 + attack_time: Int, 322 + ) 323 + } 324 + 325 + pub type UserSettings { 326 + UserSettings( 327 + music_dir: String, 328 + volume: Int, 329 + balance: Int, 330 + bass: Int, 331 + treble: Int, 332 + channel_config: Int, 333 + stereo_width: Int, 334 + eq_enabled: Bool, 335 + eq_precut: Int, 336 + eq_band_settings: List(EqBandSetting), 337 + replaygain_settings: ReplaygainSettings, 338 + compressor_settings: CompressorSettings, 339 + crossfade_enabled: Int, 340 + crossfade_fade_in_delay: Int, 341 + crossfade_fade_in_duration: Int, 342 + crossfade_fade_out_delay: Int, 343 + crossfade_fade_out_duration: Int, 344 + crossfade_fade_out_mixmode: Int, 345 + crossfeed_enabled: Bool, 346 + crossfeed_direct_gain: Int, 347 + crossfeed_cross_gain: Int, 348 + crossfeed_hf_attenuation: Int, 349 + crossfeed_hf_cutoff: Int, 350 + repeat_mode: Int, 351 + single_mode: Bool, 352 + party_mode: Bool, 353 + shuffle: Bool, 354 + player_name: String, 355 + ) 356 + } 357 + 358 + // --------------------------------------------------------------------------- 359 + // Decoders 360 + // --------------------------------------------------------------------------- 361 + 362 + fn opt_string() -> decode.Decoder(Option(String)) { 363 + decode.optional(decode.string) 364 + } 365 + 366 + fn opt_int() -> decode.Decoder(Option(Int)) { 367 + decode.optional(decode.int) 368 + } 369 + 370 + pub fn track_decoder() -> decode.Decoder(Track) { 371 + use id <- decode.optional_field("id", None, opt_string()) 372 + use title <- decode.optional_field("title", "", decode.string) 373 + use artist <- decode.optional_field("artist", "", decode.string) 374 + use album <- decode.optional_field("album", "", decode.string) 375 + use genre <- decode.optional_field("genre", "", decode.string) 376 + use disc <- decode.optional_field("disc", "", decode.string) 377 + use track_string <- decode.optional_field("trackString", "", decode.string) 378 + use year_string <- decode.optional_field("yearString", "", decode.string) 379 + use composer <- decode.optional_field("composer", "", decode.string) 380 + use comment <- decode.optional_field("comment", "", decode.string) 381 + use album_artist <- decode.optional_field("albumArtist", "", decode.string) 382 + use grouping <- decode.optional_field("grouping", "", decode.string) 383 + use discnum <- decode.optional_field("discnum", 0, decode.int) 384 + use tracknum <- decode.optional_field("tracknum", 0, decode.int) 385 + use layer <- decode.optional_field("layer", 0, decode.int) 386 + use year <- decode.optional_field("year", 0, decode.int) 387 + use bitrate <- decode.optional_field("bitrate", 0, decode.int) 388 + use frequency <- decode.optional_field("frequency", 0, decode.int) 389 + use filesize <- decode.optional_field("filesize", 0, decode.int) 390 + use length <- decode.optional_field("length", 0, decode.int) 391 + use elapsed <- decode.optional_field("elapsed", 0, decode.int) 392 + use path <- decode.optional_field("path", "", decode.string) 393 + use album_id <- decode.optional_field("albumId", None, opt_string()) 394 + use artist_id <- decode.optional_field("artistId", None, opt_string()) 395 + use genre_id <- decode.optional_field("genreId", None, opt_string()) 396 + use album_art <- decode.optional_field("albumArt", None, opt_string()) 397 + decode.success(Track( 398 + id:, 399 + title:, 400 + artist:, 401 + album:, 402 + genre:, 403 + disc:, 404 + track_string:, 405 + year_string:, 406 + composer:, 407 + comment:, 408 + album_artist:, 409 + grouping:, 410 + discnum:, 411 + tracknum:, 412 + layer:, 413 + year:, 414 + bitrate:, 415 + frequency:, 416 + filesize:, 417 + length:, 418 + elapsed:, 419 + path:, 420 + album_id:, 421 + artist_id:, 422 + genre_id:, 423 + album_art:, 424 + )) 425 + } 426 + 427 + pub fn album_decoder() -> decode.Decoder(Album) { 428 + use id <- decode.field("id", decode.string) 429 + use title <- decode.optional_field("title", "", decode.string) 430 + use artist <- decode.optional_field("artist", "", decode.string) 431 + use year <- decode.optional_field("year", 0, decode.int) 432 + use year_string <- decode.optional_field("yearString", "", decode.string) 433 + use album_art <- decode.optional_field("albumArt", None, opt_string()) 434 + use md5 <- decode.optional_field("md5", "", decode.string) 435 + use artist_id <- decode.optional_field("artistId", "", decode.string) 436 + use copyright_message <- decode.optional_field( 437 + "copyrightMessage", 438 + None, 439 + opt_string(), 440 + ) 441 + use tracks <- decode.optional_field("tracks", [], decode.list(track_decoder())) 442 + decode.success(Album( 443 + id:, 444 + title:, 445 + artist:, 446 + year:, 447 + year_string:, 448 + album_art:, 449 + md5:, 450 + artist_id:, 451 + copyright_message:, 452 + tracks:, 453 + )) 454 + } 455 + 456 + pub fn artist_decoder() -> decode.Decoder(Artist) { 457 + use id <- decode.field("id", decode.string) 458 + use name <- decode.optional_field("name", "", decode.string) 459 + use bio <- decode.optional_field("bio", None, opt_string()) 460 + use image <- decode.optional_field("image", None, opt_string()) 461 + use tracks <- decode.optional_field("tracks", [], decode.list(track_decoder())) 462 + use albums <- decode.optional_field("albums", [], decode.list(album_decoder())) 463 + decode.success(Artist(id:, name:, bio:, image:, tracks:, albums:)) 464 + } 465 + 466 + pub fn search_results_decoder() -> decode.Decoder(SearchResults) { 467 + use artists <- decode.optional_field( 468 + "artists", 469 + [], 470 + decode.list(artist_decoder()), 471 + ) 472 + use albums <- decode.optional_field( 473 + "albums", 474 + [], 475 + decode.list(album_decoder()), 476 + ) 477 + use tracks <- decode.optional_field("tracks", [], decode.list(track_decoder())) 478 + use liked_tracks <- decode.optional_field( 479 + "likedTracks", 480 + [], 481 + decode.list(track_decoder()), 482 + ) 483 + use liked_albums <- decode.optional_field( 484 + "likedAlbums", 485 + [], 486 + decode.list(album_decoder()), 487 + ) 488 + decode.success(SearchResults( 489 + artists:, 490 + albums:, 491 + tracks:, 492 + liked_tracks:, 493 + liked_albums:, 494 + )) 495 + } 496 + 497 + pub fn playlist_decoder() -> decode.Decoder(Playlist) { 498 + use amount <- decode.optional_field("amount", 0, decode.int) 499 + use index <- decode.optional_field("index", 0, decode.int) 500 + use max_playlist_size <- decode.optional_field( 501 + "maxPlaylistSize", 502 + 0, 503 + decode.int, 504 + ) 505 + use first_index <- decode.optional_field("firstIndex", 0, decode.int) 506 + use last_insert_pos <- decode.optional_field("lastInsertPos", 0, decode.int) 507 + use seed <- decode.optional_field("seed", 0, decode.int) 508 + use last_shuffled_start <- decode.optional_field( 509 + "lastShuffledStart", 510 + 0, 511 + decode.int, 512 + ) 513 + use tracks <- decode.optional_field("tracks", [], decode.list(track_decoder())) 514 + decode.success(Playlist( 515 + amount:, 516 + index:, 517 + max_playlist_size:, 518 + first_index:, 519 + last_insert_pos:, 520 + seed:, 521 + last_shuffled_start:, 522 + tracks:, 523 + )) 524 + } 525 + 526 + pub fn saved_playlist_decoder() -> decode.Decoder(SavedPlaylist) { 527 + use id <- decode.field("id", decode.string) 528 + use name <- decode.field("name", decode.string) 529 + use description <- decode.optional_field("description", None, opt_string()) 530 + use image <- decode.optional_field("image", None, opt_string()) 531 + use folder_id <- decode.optional_field("folderId", None, opt_string()) 532 + use track_count <- decode.optional_field("trackCount", 0, decode.int) 533 + use created_at <- decode.optional_field("createdAt", 0, decode.int) 534 + use updated_at <- decode.optional_field("updatedAt", 0, decode.int) 535 + decode.success(SavedPlaylist( 536 + id:, 537 + name:, 538 + description:, 539 + image:, 540 + folder_id:, 541 + track_count:, 542 + created_at:, 543 + updated_at:, 544 + )) 545 + } 546 + 547 + pub fn saved_playlist_folder_decoder() -> decode.Decoder(SavedPlaylistFolder) { 548 + use id <- decode.field("id", decode.string) 549 + use name <- decode.field("name", decode.string) 550 + use created_at <- decode.optional_field("createdAt", 0, decode.int) 551 + use updated_at <- decode.optional_field("updatedAt", 0, decode.int) 552 + decode.success(SavedPlaylistFolder(id:, name:, created_at:, updated_at:)) 553 + } 554 + 555 + pub fn smart_playlist_decoder() -> decode.Decoder(SmartPlaylist) { 556 + use id <- decode.field("id", decode.string) 557 + use name <- decode.field("name", decode.string) 558 + use description <- decode.optional_field("description", None, opt_string()) 559 + use image <- decode.optional_field("image", None, opt_string()) 560 + use folder_id <- decode.optional_field("folderId", None, opt_string()) 561 + use is_system <- decode.optional_field("isSystem", False, decode.bool) 562 + use rules <- decode.optional_field("rules", "", decode.string) 563 + use created_at <- decode.optional_field("createdAt", 0, decode.int) 564 + use updated_at <- decode.optional_field("updatedAt", 0, decode.int) 565 + decode.success(SmartPlaylist( 566 + id:, 567 + name:, 568 + description:, 569 + image:, 570 + folder_id:, 571 + is_system:, 572 + rules:, 573 + created_at:, 574 + updated_at:, 575 + )) 576 + } 577 + 578 + pub fn track_stats_decoder() -> decode.Decoder(TrackStats) { 579 + use track_id <- decode.field("trackId", decode.string) 580 + use play_count <- decode.optional_field("playCount", 0, decode.int) 581 + use skip_count <- decode.optional_field("skipCount", 0, decode.int) 582 + use last_played <- decode.optional_field("lastPlayed", None, opt_int()) 583 + use last_skipped <- decode.optional_field("lastSkipped", None, opt_int()) 584 + use updated_at <- decode.optional_field("updatedAt", 0, decode.int) 585 + decode.success(TrackStats( 586 + track_id:, 587 + play_count:, 588 + skip_count:, 589 + last_played:, 590 + last_skipped:, 591 + updated_at:, 592 + )) 593 + } 594 + 595 + pub fn bluetooth_device_decoder() -> decode.Decoder(BluetoothDevice) { 596 + use address <- decode.field("address", decode.string) 597 + use name <- decode.optional_field("name", "", decode.string) 598 + use paired <- decode.optional_field("paired", False, decode.bool) 599 + use trusted <- decode.optional_field("trusted", False, decode.bool) 600 + use connected <- decode.optional_field("connected", False, decode.bool) 601 + use rssi <- decode.optional_field("rssi", None, opt_int()) 602 + decode.success(BluetoothDevice( 603 + address:, 604 + name:, 605 + paired:, 606 + trusted:, 607 + connected:, 608 + rssi:, 609 + )) 610 + } 611 + 612 + pub fn volume_info_decoder() -> decode.Decoder(VolumeInfo) { 613 + use volume <- decode.field("volume", decode.int) 614 + use min <- decode.field("min", decode.int) 615 + use max <- decode.field("max", decode.int) 616 + decode.success(VolumeInfo(volume:, min:, max:)) 617 + } 618 + 619 + pub fn device_decoder() -> decode.Decoder(Device) { 620 + use id <- decode.field("id", decode.string) 621 + use name <- decode.optional_field("name", "", decode.string) 622 + use host <- decode.optional_field("host", "", decode.string) 623 + use ip <- decode.optional_field("ip", "", decode.string) 624 + use port <- decode.optional_field("port", 0, decode.int) 625 + use service <- decode.optional_field("service", "", decode.string) 626 + use app <- decode.optional_field("app", "", decode.string) 627 + use is_connected <- decode.optional_field("isConnected", False, decode.bool) 628 + use base_url <- decode.optional_field("baseUrl", None, opt_string()) 629 + use is_cast_device <- decode.optional_field( 630 + "isCastDevice", 631 + False, 632 + decode.bool, 633 + ) 634 + use is_source_device <- decode.optional_field( 635 + "isSourceDevice", 636 + False, 637 + decode.bool, 638 + ) 639 + use is_current_device <- decode.optional_field( 640 + "isCurrentDevice", 641 + False, 642 + decode.bool, 643 + ) 644 + decode.success(Device( 645 + id:, 646 + name:, 647 + host:, 648 + ip:, 649 + port:, 650 + service:, 651 + app:, 652 + is_connected:, 653 + base_url:, 654 + is_cast_device:, 655 + is_source_device:, 656 + is_current_device:, 657 + )) 658 + } 659 + 660 + pub fn entry_decoder() -> decode.Decoder(Entry) { 661 + use name <- decode.field("name", decode.string) 662 + use attr <- decode.optional_field("attr", 0, decode.int) 663 + use time_write <- decode.optional_field("timeWrite", 0, decode.int) 664 + use customaction <- decode.optional_field("customaction", 0, decode.int) 665 + use display_name <- decode.optional_field("displayName", None, opt_string()) 666 + decode.success(Entry(name:, attr:, time_write:, customaction:, display_name:)) 667 + } 668 + 669 + pub fn system_status_decoder() -> decode.Decoder(SystemStatus) { 670 + use resume_index <- decode.optional_field("resumeIndex", 0, decode.int) 671 + use resume_crc32 <- decode.optional_field("resumeCrc32", 0, decode.int) 672 + use resume_elapsed <- decode.optional_field("resumeElapsed", 0, decode.int) 673 + use resume_offset <- decode.optional_field("resumeOffset", 0, decode.int) 674 + use runtime <- decode.optional_field("runtime", 0, decode.int) 675 + use topruntime <- decode.optional_field("topruntime", 0, decode.int) 676 + use dircache_size <- decode.optional_field("dircacheSize", 0, decode.int) 677 + use last_screen <- decode.optional_field("lastScreen", 0, decode.int) 678 + use viewer_icon_count <- decode.optional_field( 679 + "viewerIconCount", 680 + 0, 681 + decode.int, 682 + ) 683 + use last_volume_change <- decode.optional_field( 684 + "lastVolumeChange", 685 + 0, 686 + decode.int, 687 + ) 688 + decode.success(SystemStatus( 689 + resume_index:, 690 + resume_crc32:, 691 + resume_elapsed:, 692 + resume_offset:, 693 + runtime:, 694 + topruntime:, 695 + dircache_size:, 696 + last_screen:, 697 + viewer_icon_count:, 698 + last_volume_change:, 699 + )) 700 + } 701 + 702 + pub fn eq_band_setting_decoder() -> decode.Decoder(EqBandSetting) { 703 + use cutoff <- decode.field("cutoff", decode.int) 704 + use q <- decode.field("q", decode.int) 705 + use gain <- decode.field("gain", decode.int) 706 + decode.success(EqBandSetting(cutoff:, q:, gain:)) 707 + } 708 + 709 + pub fn replaygain_settings_decoder() -> decode.Decoder(ReplaygainSettings) { 710 + use noclip <- decode.field("noclip", decode.bool) 711 + use type_ <- decode.field("type", decode.int) 712 + use preamp <- decode.field("preamp", decode.int) 713 + decode.success(ReplaygainSettings(noclip:, type_:, preamp:)) 714 + } 715 + 716 + pub fn compressor_settings_decoder() -> decode.Decoder(CompressorSettings) { 717 + use threshold <- decode.field("threshold", decode.int) 718 + use makeup_gain <- decode.field("makeupGain", decode.int) 719 + use ratio <- decode.field("ratio", decode.int) 720 + use knee <- decode.field("knee", decode.int) 721 + use release_time <- decode.field("releaseTime", decode.int) 722 + use attack_time <- decode.field("attackTime", decode.int) 723 + decode.success(CompressorSettings( 724 + threshold:, 725 + makeup_gain:, 726 + ratio:, 727 + knee:, 728 + release_time:, 729 + attack_time:, 730 + )) 731 + } 732 + 733 + pub fn user_settings_decoder() -> decode.Decoder(UserSettings) { 734 + use music_dir <- decode.optional_field("musicDir", "", decode.string) 735 + use volume <- decode.optional_field("volume", 0, decode.int) 736 + use balance <- decode.optional_field("balance", 0, decode.int) 737 + use bass <- decode.optional_field("bass", 0, decode.int) 738 + use treble <- decode.optional_field("treble", 0, decode.int) 739 + use channel_config <- decode.optional_field("channelConfig", 0, decode.int) 740 + use stereo_width <- decode.optional_field("stereoWidth", 0, decode.int) 741 + use eq_enabled <- decode.optional_field("eqEnabled", False, decode.bool) 742 + use eq_precut <- decode.optional_field("eqPrecut", 0, decode.int) 743 + use eq_band_settings <- decode.optional_field( 744 + "eqBandSettings", 745 + [], 746 + decode.list(eq_band_setting_decoder()), 747 + ) 748 + use replaygain_settings <- decode.field( 749 + "replaygainSettings", 750 + replaygain_settings_decoder(), 751 + ) 752 + use compressor_settings <- decode.field( 753 + "compressorSettings", 754 + compressor_settings_decoder(), 755 + ) 756 + use crossfade_enabled <- decode.optional_field( 757 + "crossfadeEnabled", 758 + 0, 759 + decode.int, 760 + ) 761 + use crossfade_fade_in_delay <- decode.optional_field( 762 + "crossfadeFadeInDelay", 763 + 0, 764 + decode.int, 765 + ) 766 + use crossfade_fade_in_duration <- decode.optional_field( 767 + "crossfadeFadeInDuration", 768 + 0, 769 + decode.int, 770 + ) 771 + use crossfade_fade_out_delay <- decode.optional_field( 772 + "crossfadeFadeOutDelay", 773 + 0, 774 + decode.int, 775 + ) 776 + use crossfade_fade_out_duration <- decode.optional_field( 777 + "crossfadeFadeOutDuration", 778 + 0, 779 + decode.int, 780 + ) 781 + use crossfade_fade_out_mixmode <- decode.optional_field( 782 + "crossfadeFadeOutMixmode", 783 + 0, 784 + decode.int, 785 + ) 786 + use crossfeed_enabled <- decode.optional_field( 787 + "crossfeedEnabled", 788 + False, 789 + decode.bool, 790 + ) 791 + use crossfeed_direct_gain <- decode.optional_field( 792 + "crossfeedDirectGain", 793 + 0, 794 + decode.int, 795 + ) 796 + use crossfeed_cross_gain <- decode.optional_field( 797 + "crossfeedCrossGain", 798 + 0, 799 + decode.int, 800 + ) 801 + use crossfeed_hf_attenuation <- decode.optional_field( 802 + "crossfeedHfAttenuation", 803 + 0, 804 + decode.int, 805 + ) 806 + use crossfeed_hf_cutoff <- decode.optional_field( 807 + "crossfeedHfCutoff", 808 + 0, 809 + decode.int, 810 + ) 811 + use repeat_mode <- decode.optional_field("repeatMode", 0, decode.int) 812 + use single_mode <- decode.optional_field("singleMode", False, decode.bool) 813 + use party_mode <- decode.optional_field("partyMode", False, decode.bool) 814 + use shuffle <- decode.optional_field("shuffle", False, decode.bool) 815 + use player_name <- decode.optional_field("playerName", "", decode.string) 816 + decode.success(UserSettings( 817 + music_dir:, 818 + volume:, 819 + balance:, 820 + bass:, 821 + treble:, 822 + channel_config:, 823 + stereo_width:, 824 + eq_enabled:, 825 + eq_precut:, 826 + eq_band_settings:, 827 + replaygain_settings:, 828 + compressor_settings:, 829 + crossfade_enabled:, 830 + crossfade_fade_in_delay:, 831 + crossfade_fade_in_duration:, 832 + crossfade_fade_out_delay:, 833 + crossfade_fade_out_duration:, 834 + crossfade_fade_out_mixmode:, 835 + crossfeed_enabled:, 836 + crossfeed_direct_gain:, 837 + crossfeed_cross_gain:, 838 + crossfeed_hf_attenuation:, 839 + crossfeed_hf_cutoff:, 840 + repeat_mode:, 841 + single_mode:, 842 + party_mode:, 843 + shuffle:, 844 + player_name:, 845 + )) 846 + }
+77
sdk/gleam/test/rockbox_test.gleam
··· 1 + import gleam/option 2 + import gleeunit 3 + import rockbox 4 + import rockbox/types 5 + 6 + pub fn main() -> Nil { 7 + gleeunit.main() 8 + } 9 + 10 + pub fn default_url_test() { 11 + let client = rockbox.new() |> rockbox.connect 12 + assert rockbox.http_url(client) == "http://localhost:6062/graphql" 13 + } 14 + 15 + pub fn host_override_test() { 16 + let client = 17 + rockbox.new() 18 + |> rockbox.host("rockbox.local") 19 + |> rockbox.port(8080) 20 + |> rockbox.connect 21 + 22 + assert rockbox.http_url(client) == "http://rockbox.local:8080/graphql" 23 + } 24 + 25 + pub fn url_override_test() { 26 + let client = 27 + rockbox.new() 28 + |> rockbox.host("ignored") 29 + |> rockbox.url("https://api.example.com/graphql") 30 + |> rockbox.connect 31 + 32 + assert rockbox.http_url(client) == "https://api.example.com/graphql" 33 + } 34 + 35 + pub fn at_helper_test() { 36 + let client = rockbox.at(host: "192.168.1.10", port: 6062) 37 + assert rockbox.http_url(client) == "http://192.168.1.10:6062/graphql" 38 + } 39 + 40 + pub fn playback_status_round_trip_test() { 41 + assert types.playback_status_from_int(0) == types.Stopped 42 + assert types.playback_status_from_int(1) == types.Playing 43 + assert types.playback_status_from_int(3) == types.Paused 44 + 45 + assert types.playback_status_to_int(types.Stopped) == 0 46 + assert types.playback_status_to_int(types.Playing) == 1 47 + assert types.playback_status_to_int(types.Paused) == 3 48 + } 49 + 50 + pub fn insert_position_test() { 51 + assert types.insert_position_to_int(types.Next) == 0 52 + assert types.insert_position_to_int(types.AfterCurrent) == 1 53 + assert types.insert_position_to_int(types.Last) == 2 54 + assert types.insert_position_to_int(types.First) == 3 55 + } 56 + 57 + pub fn is_directory_test() { 58 + let dir = 59 + types.Entry( 60 + name: "Music", 61 + attr: 0x10, 62 + time_write: 0, 63 + customaction: 0, 64 + display_name: option.None, 65 + ) 66 + let file = 67 + types.Entry( 68 + name: "song.mp3", 69 + attr: 0x00, 70 + time_write: 0, 71 + customaction: 0, 72 + display_name: option.None, 73 + ) 74 + 75 + assert types.is_directory(dir) 76 + assert !types.is_directory(file) 77 + }
+17
sdk/python/.gitignore
··· 1 + # uv 2 + .venv/ 3 + .python-version 4 + 5 + # Python 6 + __pycache__/ 7 + *.py[cod] 8 + *.egg-info/ 9 + .pytest_cache/ 10 + .mypy_cache/ 11 + .ruff_cache/ 12 + 13 + # Build artifacts 14 + dist/ 15 + build/ 16 + *.whl 17 + *.tar.gz
+1
sdk/python/.python-version
··· 1 + 3.12
+21
sdk/python/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app> 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+224
sdk/python/README.md
··· 1 + # rockbox-sdk 2 + 3 + Async Python SDK for [Rockbox](https://www.rockbox.org) — a typed, batteries-included 4 + client for the GraphQL API exposed by `rockboxd`. 5 + 6 + ```python 7 + import asyncio 8 + from rockbox_sdk import RockboxClient, PlaybackStatus 9 + 10 + async def main(): 11 + async with RockboxClient(host="localhost") as client: 12 + track = await client.playback.current_track() 13 + if track: 14 + print(f"Now: {track.title} — {track.artist}") 15 + if await client.playback.status() == PlaybackStatus.PAUSED: 16 + await client.playback.resume() 17 + 18 + asyncio.run(main()) 19 + ``` 20 + 21 + ## Highlights 22 + 23 + - **Async-first** — built on `httpx` + `websockets`. Use `await` everywhere. 24 + - **Domain-namespaced API** — `client.playback.*`, `client.library.*`, `client.sound.*`, … 25 + - **Typed responses** — every reply is a Pydantic model with snake_case fields. 26 + - **Real-time events** — `connect()` opens a WebSocket and forwards 27 + `track:changed` / `status:changed` / `playlist:changed` to listeners. 28 + - **Builder API** — `RockboxClient.builder().host(...).port(...).build()`. 29 + - **Plugin system** — Jellyfin-style install/uninstall lifecycle. 30 + - **Python-friendly** — context manager, decorator listeners, dataclass inputs. 31 + 32 + ## Install 33 + 34 + ```sh 35 + uv add rockbox-sdk 36 + # or 37 + pip install rockbox-sdk 38 + ``` 39 + 40 + Requires Python 3.10+ and a running `rockboxd` (default port 6062). 41 + 42 + ## Try it in the REPL 43 + 44 + The SDK is async-first, so the easiest way to poke at a live `rockboxd` is 45 + Python's built-in async REPL — `await` works at the top level: 46 + 47 + ```sh 48 + uv run python -m asyncio 49 + ``` 50 + 51 + ```python 52 + >>> from rockbox_sdk import RockboxClient, PlaybackStatus 53 + >>> client = RockboxClient(host="localhost", port=6062) 54 + >>> await client.playback.status() 55 + <PlaybackStatus.PLAYING: 1> 56 + >>> track = await client.playback.current_track() 57 + >>> track.title, track.artist 58 + ('Money', 'Pink Floyd') 59 + >>> await client.sound.get_volume() 60 + VolumeInfo(volume=-12, min=-74, max=6) 61 + >>> await client.library.search("daft punk") 62 + >>> await client.aclose() 63 + ``` 64 + 65 + You can also test offline — models, enums, and the builder don't need a server: 66 + 67 + ```python 68 + >>> from rockbox_sdk import RockboxClient, Track, InsertPosition 69 + >>> Track.model_validate({"title": "Money", "albumArt": "x.jpg"}).album_art 70 + 'x.jpg' 71 + >>> RockboxClient.builder().host("nas.local").build()._config.resolve_http_url() 72 + 'http://nas.local:6062/graphql' 73 + ``` 74 + 75 + If you'd rather use the plain `python` REPL, wrap each call in `asyncio.run(...)`: 76 + 77 + ```python 78 + >>> import asyncio 79 + >>> from rockbox_sdk import RockboxClient 80 + >>> client = RockboxClient() 81 + >>> asyncio.run(client.playback.status()) 82 + ``` 83 + 84 + The async REPL is much nicer — subscriptions (`await client.connect()`) also keep 85 + firing in the background between prompts. 86 + 87 + ## Configure 88 + 89 + ```python 90 + from rockbox_sdk import RockboxClient 91 + 92 + # Direct kwargs 93 + client = RockboxClient(host="192.168.1.42", port=6062) 94 + 95 + # Or fluent builder 96 + client = ( 97 + RockboxClient.builder() 98 + .host("nas.local") 99 + .port(6062) 100 + .timeout(15) 101 + .build() 102 + ) 103 + 104 + # Or full URL override 105 + client = RockboxClient( 106 + http_url="http://nas.local:6062/graphql", 107 + ws_url="ws://nas.local:6062/graphql", 108 + ) 109 + ``` 110 + 111 + Always call `await client.aclose()` when you're done — or use it as an 112 + async context manager: 113 + 114 + ```python 115 + async with RockboxClient() as client: 116 + ... 117 + ``` 118 + 119 + ## Domains 120 + 121 + | Namespace | What it does | 122 + | -------------------------- | ------------------------------------------------------ | 123 + | `client.playback` | Transport (`play`/`pause`/`seek`), play helpers | 124 + | `client.library` | Albums, artists, tracks, search, likes, scan | 125 + | `client.playlist` | The active queue (insert/remove/shuffle/start) | 126 + | `client.saved_playlists` | Persistent playlists & folders | 127 + | `client.smart_playlists` | Rule-based playlists & listening stats | 128 + | `client.sound` | Volume control | 129 + | `client.settings` | Global EQ / replaygain / crossfade / shuffle / … | 130 + | `client.system` | Version, runtime info | 131 + | `client.browse` | Filesystem & UPnP browser | 132 + | `client.devices` | Cast / source device discovery | 133 + | `client.bluetooth` | Bluetooth pairing & scanning (Linux only) | 134 + 135 + ## Real-time events 136 + 137 + ```python 138 + from rockbox_sdk import RockboxClient, TRACK_CHANGED, STATUS_CHANGED 139 + 140 + async with RockboxClient() as client: 141 + await client.connect() # opens the WebSocket 142 + 143 + @client.on(TRACK_CHANGED) 144 + async def on_track(track): 145 + print(f"▶ {track.title} — {track.artist}") 146 + 147 + @client.on(STATUS_CHANGED) 148 + def on_status(raw_status): 149 + print(f"◐ status = {raw_status}") 150 + 151 + await asyncio.Event().wait() # run forever 152 + ``` 153 + 154 + Convenience wrappers exist (`client.on_track_changed(...)`, 155 + `client.on_status_changed(...)`, `client.on_playlist_changed(...)`). 156 + 157 + ## Plugins 158 + 159 + A plugin is anything matching the `RockboxPlugin` protocol — a name, version, 160 + `install(context)`, and optionally `uninstall()`: 161 + 162 + ```python 163 + from rockbox_sdk import RockboxClient, PlaybackStatus, RockboxPlugin 164 + 165 + class SleepTimer: 166 + name = "sleep-timer" 167 + version = "1.0.0" 168 + description = "Stop playback after N minutes" 169 + 170 + def __init__(self, minutes: int) -> None: 171 + self.minutes = minutes 172 + self._task: asyncio.Task | None = None 173 + 174 + def install(self, ctx): 175 + async def fire(): 176 + await asyncio.sleep(self.minutes * 60) 177 + await ctx.query("mutation { hardStop }") 178 + 179 + self._task = asyncio.create_task(fire()) 180 + 181 + @ctx.events.on("status:changed") 182 + def cancel_on_stop(status: int): 183 + if status == PlaybackStatus.STOPPED and self._task: 184 + self._task.cancel() 185 + 186 + def uninstall(self): 187 + if self._task: 188 + self._task.cancel() 189 + 190 + async with RockboxClient() as client: 191 + await client.connect() 192 + await client.use(SleepTimer(30)) 193 + ``` 194 + 195 + ## Raw GraphQL escape hatch 196 + 197 + ```python 198 + data = await client.query( 199 + "query Volume { volume { volume min max } }" 200 + ) 201 + ``` 202 + 203 + ## Examples 204 + 205 + See `examples/` for runnable scripts mirroring the TypeScript SDK examples: 206 + 207 + - `01-basic-playback.py` 208 + - `02-now-playing.py` 209 + - `03-library-search.py` 210 + - `04-queue-management.py` 211 + - `05-volume-control.py` 212 + - `06-plugin-sleep-timer.py` 213 + 214 + Run with: 215 + 216 + ```sh 217 + uv run python examples/01_basic_playback.py 218 + ``` 219 + 220 + --- 221 + 222 + ## License 223 + 224 + MIT License. See [LICENSE](./LICENSE) for details.
+40
sdk/python/examples/01_basic_playback.py
··· 1 + """01 — Basic playback. 2 + 3 + Inspect the current track, then either pause or resume based on state. 4 + Idempotent: run twice and it toggles between Playing and Paused. 5 + 6 + uv run python examples/01_basic_playback.py 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import asyncio 12 + 13 + from _client import create_client, fmt_time # type: ignore[import-not-found] 14 + 15 + from rockbox_sdk import PlaybackStatus 16 + 17 + 18 + async def main() -> None: 19 + async with create_client() as client: 20 + status = await client.playback.status() 21 + print(f"Status: {status.name}") 22 + 23 + track = await client.playback.current_track() 24 + if track: 25 + pct = round((track.elapsed / track.length) * 100) if track.length else 0 26 + print(f"Now: {track.title} — {track.artist}") 27 + print(f" {fmt_time(track.elapsed)} / {fmt_time(track.length)} ({pct}%)") 28 + else: 29 + print("Nothing is playing.") 30 + 31 + if status == PlaybackStatus.PLAYING: 32 + await client.playback.pause() 33 + print("→ paused") 34 + elif status == PlaybackStatus.PAUSED: 35 + await client.playback.resume() 36 + print("→ resumed") 37 + 38 + 39 + if __name__ == "__main__": 40 + asyncio.run(main())
+51
sdk/python/examples/02_now_playing.py
··· 1 + """02 — Now playing (real-time subscriptions). 2 + 3 + Opens a WebSocket and prints every track / status / queue change. Ctrl+C exits. 4 + 5 + uv run python examples/02_now_playing.py 6 + """ 7 + 8 + from __future__ import annotations 9 + 10 + import asyncio 11 + import contextlib 12 + 13 + from _client import create_client # type: ignore[import-not-found] 14 + 15 + from rockbox_sdk import PlaybackStatus, Playlist, Track 16 + 17 + 18 + async def main() -> None: 19 + async with create_client() as client: 20 + await client.connect() 21 + 22 + @client.on_track_changed 23 + def _track(track: Track) -> None: 24 + print(f"▶ {track.title} — {track.artist} [{track.album}]") 25 + 26 + @client.on_status_changed 27 + def _status(raw: int) -> None: 28 + try: 29 + label = PlaybackStatus(raw).name 30 + except ValueError: 31 + label = str(raw) 32 + print(f"◐ {label}") 33 + 34 + @client.on_playlist_changed 35 + def _queue(q: Playlist) -> None: 36 + print(f"☰ queue updated — {q.amount} tracks (index {q.index})") 37 + 38 + @client.on("ws:error") 39 + def _err(err: Exception) -> None: 40 + print(f"✗ websocket error: {err}") 41 + 42 + print("Listening for events. Press Ctrl+C to exit.") 43 + with contextlib.suppress(asyncio.CancelledError): 44 + await asyncio.Event().wait() 45 + 46 + 47 + if __name__ == "__main__": 48 + try: 49 + asyncio.run(main()) 50 + except KeyboardInterrupt: 51 + print("\nDisconnecting...")
+42
sdk/python/examples/03_library_search.py
··· 1 + """03 — Search the library. 2 + 3 + Search the library, print a summary, start playing the first matching album. 4 + 5 + uv run python examples/03_library_search.py "pink floyd" 6 + """ 7 + 8 + from __future__ import annotations 9 + 10 + import asyncio 11 + import sys 12 + 13 + from _client import create_client # type: ignore[import-not-found] 14 + 15 + 16 + async def main(term: str) -> None: 17 + async with create_client() as client: 18 + results = await client.library.search(term) 19 + 20 + print(f'Search: "{term}"') 21 + print(f" Artists : {len(results.artists)}") 22 + print(f" Albums : {len(results.albums)}") 23 + print(f" Tracks : {len(results.tracks)}") 24 + print(f" Liked albums : {len(results.liked_albums)}") 25 + print(f" Liked tracks : {len(results.liked_tracks)}\n") 26 + 27 + print("Top albums:") 28 + for a in results.albums[:5]: 29 + copyright = f" © {a.copyright_message}" if a.copyright_message else "" 30 + print(f" • {a.title} — {a.artist} ({a.year}){copyright}") 31 + 32 + if results.albums: 33 + first = results.albums[0] 34 + print(f"\nPlaying: {first.title}") 35 + await client.playback.play_album(first.id, shuffle=False) 36 + 37 + 38 + if __name__ == "__main__": 39 + if len(sys.argv) < 2: 40 + print("usage: python examples/03_library_search.py <search term>") 41 + sys.exit(1) 42 + asyncio.run(main(sys.argv[1]))
+37
sdk/python/examples/04_queue_management.py
··· 1 + """04 — Queue management. 2 + 3 + Show the current queue, then insert tracks from the first album in the library 4 + at the end of the queue. 5 + 6 + uv run python examples/04_queue_management.py 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import asyncio 12 + 13 + from _client import create_client # type: ignore[import-not-found] 14 + 15 + from rockbox_sdk import InsertPosition 16 + 17 + 18 + async def main() -> None: 19 + async with create_client() as client: 20 + queue = await client.playlist.current() 21 + print(f"Queue has {queue.amount} tracks (index {queue.index}):") 22 + for i, t in enumerate(queue.tracks[:5]): 23 + marker = "▶" if i == queue.index else " " 24 + print(f" {marker} {i:>3} {t.title} — {t.artist}") 25 + if queue.amount > 5: 26 + print(f" … and {queue.amount - 5} more") 27 + 28 + albums = await client.library.albums() 29 + if albums: 30 + album = albums[0] 31 + print(f"\nAppending album: {album.title} — {album.artist}") 32 + await client.playlist.insert_album(album.id, InsertPosition.LAST) 33 + print("→ done") 34 + 35 + 36 + if __name__ == "__main__": 37 + asyncio.run(main())
+34
sdk/python/examples/05_volume_control.py
··· 1 + """05 — Volume control. 2 + 3 + Read current volume (with min/max range) and bump it up by one step. 4 + 5 + uv run python examples/05_volume_control.py # show + step up 6 + uv run python examples/05_volume_control.py -3 # step down 3 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import asyncio 12 + import sys 13 + 14 + from _client import create_client # type: ignore[import-not-found] 15 + 16 + 17 + async def main(delta: int) -> None: 18 + async with create_client() as client: 19 + before = await client.sound.get_volume() 20 + rng = before.max - before.min 21 + filled = round(((before.volume - before.min) / rng) * 20) if rng > 0 else 0 22 + bar = "█" * filled + "░" * max(0, 20 - filled) 23 + 24 + print(f"Volume: {before.volume} dB (range {before.min} … {before.max})") 25 + print(f" {bar}") 26 + 27 + after = await client.sound.adjust_volume(delta) 28 + sign = f"+{delta}" if delta >= 0 else str(delta) 29 + print(f"\nAdjusted by {sign} → {after} dB") 30 + 31 + 32 + if __name__ == "__main__": 33 + delta = int(sys.argv[1]) if len(sys.argv) > 1 else 1 34 + asyncio.run(main(delta))
+71
sdk/python/examples/06_plugin_sleep_timer.py
··· 1 + """06 — Plugin: sleep timer. 2 + 3 + Stops playback after N minutes. If the user stops playback manually before the 4 + timer fires, the plugin cancels itself. 5 + 6 + uv run python examples/06_plugin_sleep_timer.py # default 30 min 7 + uv run python examples/06_plugin_sleep_timer.py 5 # 5 minutes 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import asyncio 13 + import contextlib 14 + import sys 15 + from datetime import datetime, timedelta 16 + 17 + from _client import create_client # type: ignore[import-not-found] 18 + 19 + from rockbox_sdk import PlaybackStatus, PluginContext 20 + 21 + 22 + class SleepTimer: 23 + name = "sleep-timer" 24 + version = "1.0.0" 25 + 26 + def __init__(self, minutes: int) -> None: 27 + self.minutes = minutes 28 + self.description = f"Stop playback after {minutes} minute(s)" 29 + self._task: asyncio.Task[None] | None = None 30 + 31 + def install(self, ctx: PluginContext) -> None: 32 + fire_at = datetime.now() + timedelta(minutes=self.minutes) 33 + print(f"💤 Sleep timer armed — will stop playback at {fire_at:%H:%M:%S}") 34 + 35 + async def fire() -> None: 36 + try: 37 + await asyncio.sleep(self.minutes * 60) 38 + except asyncio.CancelledError: 39 + return 40 + print("💤 Time's up — stopping playback.") 41 + await ctx.query("mutation { hardStop }") 42 + 43 + self._task = asyncio.create_task(fire()) 44 + 45 + @ctx.events.on("status:changed") 46 + def cancel_on_stop(status: int) -> None: 47 + if status == PlaybackStatus.STOPPED and self._task and not self._task.done(): 48 + self._task.cancel() 49 + print("💤 Playback stopped manually — sleep timer cancelled.") 50 + 51 + def uninstall(self) -> None: 52 + if self._task and not self._task.done(): 53 + self._task.cancel() 54 + 55 + 56 + async def main(minutes: int) -> None: 57 + async with create_client() as client: 58 + await client.connect() 59 + await client.use(SleepTimer(minutes)) 60 + 61 + print("Plugin installed. Press Ctrl+C to cancel and exit.") 62 + with contextlib.suppress(asyncio.CancelledError): 63 + await asyncio.Event().wait() 64 + 65 + 66 + if __name__ == "__main__": 67 + minutes = int(sys.argv[1]) if len(sys.argv) > 1 else 30 68 + try: 69 + asyncio.run(main(minutes)) 70 + except KeyboardInterrupt: 71 + print("\nbye")
+23
sdk/python/examples/_client.py
··· 1 + """Shared client factory used by every example. 2 + 3 + Override the host/port via env: ``ROCKBOX_HOST``, ``ROCKBOX_PORT``. 4 + """ 5 + 6 + from __future__ import annotations 7 + 8 + import os 9 + 10 + from rockbox_sdk import RockboxClient 11 + 12 + 13 + def create_client() -> RockboxClient: 14 + return RockboxClient( 15 + host=os.environ.get("ROCKBOX_HOST", "localhost"), 16 + port=int(os.environ.get("ROCKBOX_PORT", "6062")), 17 + ) 18 + 19 + 20 + def fmt_time(ms: int) -> str: 21 + """Format milliseconds as ``M:SS``.""" 22 + total = max(0, ms // 1000) 23 + return f"{total // 60}:{total % 60:02d}"
+68
sdk/python/pyproject.toml
··· 1 + [project] 2 + name = "rockbox-sdk" 3 + version = "0.1.0" 4 + description = "Python SDK for Rockbox — async GraphQL client with subscriptions and plugins" 5 + readme = "README.md" 6 + requires-python = ">=3.10" 7 + license = { text = "MIT" } 8 + authors = [ 9 + { name = "Tsiry Sandratraina", email = "tsiry.sndr@rocksky.app" }, 10 + ] 11 + keywords = ["rockbox", "graphql", "audio", "music-player", "sdk"] 12 + classifiers = [ 13 + "Development Status :: 4 - Beta", 14 + "Framework :: AsyncIO", 15 + "Intended Audience :: Developers", 16 + "Programming Language :: Python :: 3", 17 + "Programming Language :: Python :: 3.10", 18 + "Programming Language :: Python :: 3.11", 19 + "Programming Language :: Python :: 3.12", 20 + "Programming Language :: Python :: 3.13", 21 + "Topic :: Multimedia :: Sound/Audio", 22 + "Topic :: Software Development :: Libraries", 23 + "Typing :: Typed", 24 + ] 25 + dependencies = [ 26 + "httpx>=0.27", 27 + "websockets>=13", 28 + "pydantic>=2.6", 29 + ] 30 + 31 + [project.urls] 32 + Homepage = "https://github.com/tsirysndr/rockbox-zig" 33 + Repository = "https://github.com/tsirysndr/rockbox-zig" 34 + Issues = "https://github.com/tsirysndr/rockbox-zig/issues" 35 + 36 + [dependency-groups] 37 + dev = [ 38 + "pytest>=8.0", 39 + "pytest-asyncio>=0.24", 40 + "pytest-httpx>=0.32", 41 + "ruff>=0.6", 42 + "mypy>=1.10", 43 + ] 44 + 45 + [build-system] 46 + requires = ["uv_build>=0.11.6,<0.12.0"] 47 + build-backend = "uv_build" 48 + 49 + [tool.uv.build-backend] 50 + module-name = "rockbox_sdk" 51 + module-root = "src" 52 + 53 + [tool.pytest.ini_options] 54 + asyncio_mode = "auto" 55 + testpaths = ["tests"] 56 + 57 + [tool.ruff] 58 + line-length = 100 59 + target-version = "py310" 60 + 61 + [tool.ruff.lint] 62 + select = ["E", "F", "I", "UP", "B", "SIM", "N"] 63 + ignore = ["E501", "SIM105"] 64 + 65 + [tool.mypy] 66 + python_version = "3.10" 67 + strict = true 68 + warn_unused_ignores = true
+146
sdk/python/src/rockbox_sdk/__init__.py
··· 1 + """Python SDK for Rockbox. 2 + 3 + Quick start:: 4 + 5 + import asyncio 6 + from rockbox_sdk import RockboxClient, PlaybackStatus 7 + 8 + async def main(): 9 + async with RockboxClient(host="localhost") as client: 10 + track = await client.playback.current_track() 11 + if track: 12 + print(f"Now: {track.title} — {track.artist}") 13 + await client.playback.play() 14 + 15 + asyncio.run(main()) 16 + """ 17 + 18 + from __future__ import annotations 19 + 20 + from .api import ( 21 + BluetoothApi, 22 + BrowseApi, 23 + CreatePlaylistInput, 24 + CreateSmartPlaylistInput, 25 + DevicesApi, 26 + LibraryApi, 27 + PlaybackApi, 28 + PlaylistApi, 29 + SavedPlaylistsApi, 30 + SettingsApi, 31 + SmartPlaylistsApi, 32 + SoundApi, 33 + SystemApi, 34 + UpdatePlaylistInput, 35 + UpdateSmartPlaylistInput, 36 + ) 37 + from .client import RockboxClient, RockboxClientBuilder, RockboxClientConfig 38 + from .errors import RockboxError, RockboxGraphQLError, RockboxNetworkError 39 + from .events import ( 40 + PLAYLIST_CHANGED, 41 + STATUS_CHANGED, 42 + TRACK_CHANGED, 43 + WS_CLOSE, 44 + WS_ERROR, 45 + WS_OPEN, 46 + EventEmitter, 47 + ) 48 + from .plugin import PluginContext, PluginRegistry, RockboxPlugin 49 + from .types import ( 50 + Album, 51 + Artist, 52 + BluetoothDevice, 53 + ChannelConfig, 54 + CompressorSettings, 55 + Device, 56 + Entry, 57 + EqBandSetting, 58 + InsertPosition, 59 + PartialUserSettings, 60 + PlaybackStatus, 61 + Playlist, 62 + RepeatMode, 63 + ReplaygainSettings, 64 + ReplaygainType, 65 + SavedPlaylist, 66 + SavedPlaylistFolder, 67 + SearchResults, 68 + SmartPlaylist, 69 + SystemStatus, 70 + Track, 71 + TrackStats, 72 + UserSettings, 73 + VolumeInfo, 74 + is_directory, 75 + ) 76 + 77 + __version__ = "0.1.0" 78 + 79 + __all__ = [ 80 + # Client 81 + "RockboxClient", 82 + "RockboxClientBuilder", 83 + "RockboxClientConfig", 84 + # APIs (re-exported in case users want to mock them) 85 + "BluetoothApi", 86 + "BrowseApi", 87 + "DevicesApi", 88 + "LibraryApi", 89 + "PlaybackApi", 90 + "PlaylistApi", 91 + "SavedPlaylistsApi", 92 + "SettingsApi", 93 + "SmartPlaylistsApi", 94 + "SoundApi", 95 + "SystemApi", 96 + # Inputs 97 + "CreatePlaylistInput", 98 + "CreateSmartPlaylistInput", 99 + "UpdatePlaylistInput", 100 + "UpdateSmartPlaylistInput", 101 + # Errors 102 + "RockboxError", 103 + "RockboxGraphQLError", 104 + "RockboxNetworkError", 105 + # Plugin system 106 + "PluginContext", 107 + "PluginRegistry", 108 + "RockboxPlugin", 109 + # Events 110 + "EventEmitter", 111 + "TRACK_CHANGED", 112 + "STATUS_CHANGED", 113 + "PLAYLIST_CHANGED", 114 + "WS_OPEN", 115 + "WS_CLOSE", 116 + "WS_ERROR", 117 + # Enums 118 + "ChannelConfig", 119 + "InsertPosition", 120 + "PlaybackStatus", 121 + "RepeatMode", 122 + "ReplaygainType", 123 + # Models 124 + "Album", 125 + "Artist", 126 + "BluetoothDevice", 127 + "CompressorSettings", 128 + "Device", 129 + "Entry", 130 + "EqBandSetting", 131 + "PartialUserSettings", 132 + "Playlist", 133 + "ReplaygainSettings", 134 + "SavedPlaylist", 135 + "SavedPlaylistFolder", 136 + "SearchResults", 137 + "SmartPlaylist", 138 + "SystemStatus", 139 + "Track", 140 + "TrackStats", 141 + "UserSettings", 142 + "VolumeInfo", 143 + # Helpers 144 + "is_directory", 145 + "__version__", 146 + ]
+35
sdk/python/src/rockbox_sdk/api/__init__.py
··· 1 + """Domain API namespaces exposed on :class:`RockboxClient`.""" 2 + 3 + from .bluetooth import BluetoothApi 4 + from .browse import BrowseApi 5 + from .devices import DevicesApi 6 + from .library import LibraryApi 7 + from .playback import PlaybackApi 8 + from .playlist import PlaylistApi 9 + from .saved_playlists import CreatePlaylistInput, SavedPlaylistsApi, UpdatePlaylistInput 10 + from .settings import SettingsApi 11 + from .smart_playlists import ( 12 + CreateSmartPlaylistInput, 13 + SmartPlaylistsApi, 14 + UpdateSmartPlaylistInput, 15 + ) 16 + from .sound import SoundApi 17 + from .system import SystemApi 18 + 19 + __all__ = [ 20 + "BluetoothApi", 21 + "BrowseApi", 22 + "CreatePlaylistInput", 23 + "CreateSmartPlaylistInput", 24 + "DevicesApi", 25 + "LibraryApi", 26 + "PlaybackApi", 27 + "PlaylistApi", 28 + "SavedPlaylistsApi", 29 + "SettingsApi", 30 + "SmartPlaylistsApi", 31 + "SoundApi", 32 + "SystemApi", 33 + "UpdatePlaylistInput", 34 + "UpdateSmartPlaylistInput", 35 + ]
+29
sdk/python/src/rockbox_sdk/api/_fragments.py
··· 1 + """Reusable GraphQL fragments. Inlined verbatim into queries — keep in sync with the schema.""" 2 + 3 + TRACK_FIELDS = """ 4 + fragment TrackFields on Track { 5 + id title artist album genre disc trackString yearString 6 + composer comment albumArtist grouping 7 + discnum tracknum layer year bitrate frequency 8 + filesize length elapsed path 9 + albumId artistId genreId albumArt 10 + } 11 + """ 12 + 13 + ALBUM_FIELDS = """ 14 + fragment AlbumFields on Album { 15 + id title artist year yearString albumArt md5 artistId copyrightMessage 16 + } 17 + """ 18 + 19 + ARTIST_FIELDS = """ 20 + fragment ArtistFields on Artist { 21 + id name bio image 22 + } 23 + """ 24 + 25 + BLUETOOTH_DEVICE_FIELDS = """ 26 + fragment BluetoothDeviceFields on BluetoothDevice { 27 + address name paired trusted connected rssi 28 + } 29 + """
+43
sdk/python/src/rockbox_sdk/api/bluetooth.py
··· 1 + """Bluetooth pairing and discovery (Linux-only on the server).""" 2 + 3 + from __future__ import annotations 4 + 5 + from ..transport import HttpTransport 6 + from ..types import BluetoothDevice 7 + from ._fragments import BLUETOOTH_DEVICE_FIELDS 8 + 9 + 10 + class BluetoothApi: 11 + def __init__(self, http: HttpTransport) -> None: 12 + self._http = http 13 + 14 + async def devices(self) -> list[BluetoothDevice]: 15 + """List paired/known Bluetooth devices.""" 16 + data = await self._http.execute( 17 + f"{BLUETOOTH_DEVICE_FIELDS} " 18 + "query BluetoothDevices { bluetoothDevices { ...BluetoothDeviceFields } }" 19 + ) 20 + return [BluetoothDevice.model_validate(d) for d in data.get("bluetoothDevices", [])] 21 + 22 + async def scan(self, timeout_secs: int | None = None) -> list[BluetoothDevice]: 23 + """Trigger a scan for nearby devices and return discoveries.""" 24 + data = await self._http.execute( 25 + f"{BLUETOOTH_DEVICE_FIELDS} " 26 + "mutation BluetoothScan($timeoutSecs: Int) " 27 + "{ bluetoothScan(timeoutSecs: $timeoutSecs) { ...BluetoothDeviceFields } }", 28 + {"timeoutSecs": timeout_secs}, 29 + ) 30 + return [BluetoothDevice.model_validate(d) for d in data.get("bluetoothScan", [])] 31 + 32 + async def connect(self, address: str) -> None: 33 + await self._http.execute( 34 + "mutation BluetoothConnect($address: String!) { bluetoothConnect(address: $address) }", 35 + {"address": address}, 36 + ) 37 + 38 + async def disconnect(self, address: str) -> None: 39 + await self._http.execute( 40 + "mutation BluetoothDisconnect($address: String!) " 41 + "{ bluetoothDisconnect(address: $address) }", 42 + {"address": address}, 43 + )
+26
sdk/python/src/rockbox_sdk/api/browse.py
··· 1 + """Filesystem browser (also surfaces UPnP entries).""" 2 + 3 + from __future__ import annotations 4 + 5 + from ..transport import HttpTransport 6 + from ..types import Entry 7 + 8 + 9 + class BrowseApi: 10 + def __init__(self, http: HttpTransport) -> None: 11 + self._http = http 12 + 13 + async def entries(self, path: str | None = None) -> list[Entry]: 14 + data = await self._http.execute( 15 + "query Browse($path: String) " 16 + "{ treeGetEntries(path: $path) " 17 + "{ name attr timeWrite customaction displayName } }", 18 + {"path": path}, 19 + ) 20 + return [Entry.model_validate(e) for e in data.get("treeGetEntries", [])] 21 + 22 + async def directories(self, path: str | None = None) -> list[Entry]: 23 + return [e for e in await self.entries(path) if e.is_directory] 24 + 25 + async def files(self, path: str | None = None) -> list[Entry]: 26 + return [e for e in await self.entries(path) if not e.is_directory]
+40
sdk/python/src/rockbox_sdk/api/devices.py
··· 1 + """Cast / source device discovery and control.""" 2 + 3 + from __future__ import annotations 4 + 5 + from ..transport import HttpTransport 6 + from ..types import Device 7 + 8 + _FIELDS = ( 9 + "id name host ip port service app isConnected " 10 + "baseUrl isCastDevice isSourceDevice isCurrentDevice" 11 + ) 12 + 13 + 14 + class DevicesApi: 15 + def __init__(self, http: HttpTransport) -> None: 16 + self._http = http 17 + 18 + async def list(self) -> list[Device]: 19 + data = await self._http.execute( 20 + f"query Devices {{ devices {{ {_FIELDS} }} }}" 21 + ) 22 + return [Device.model_validate(d) for d in data.get("devices", [])] 23 + 24 + async def get(self, id: str) -> Device | None: 25 + data = await self._http.execute( 26 + f"query Device($id: String!) {{ device(id: $id) {{ {_FIELDS} }} }}", 27 + {"id": id}, 28 + ) 29 + raw = data.get("device") 30 + return Device.model_validate(raw) if raw is not None else None 31 + 32 + async def connect(self, id: str) -> None: 33 + await self._http.execute( 34 + "mutation ConnectDevice($id: String!) { connect(id: $id) }", {"id": id} 35 + ) 36 + 37 + async def disconnect(self, id: str) -> None: 38 + await self._http.execute( 39 + "mutation DisconnectDevice($id: String!) { disconnect(id: $id) }", {"id": id} 40 + )
+123
sdk/python/src/rockbox_sdk/api/library.py
··· 1 + """Library: albums, artists, tracks, search, likes, scan.""" 2 + 3 + from __future__ import annotations 4 + 5 + from ..transport import HttpTransport 6 + from ..types import Album, Artist, SearchResults, Track 7 + from ._fragments import ALBUM_FIELDS, ARTIST_FIELDS, TRACK_FIELDS 8 + 9 + 10 + class LibraryApi: 11 + def __init__(self, http: HttpTransport) -> None: 12 + self._http = http 13 + 14 + # --- albums --------------------------------------------------------- 15 + 16 + async def albums(self) -> list[Album]: 17 + data = await self._http.execute( 18 + f"{ALBUM_FIELDS} query Albums " 19 + "{ albums { ...AlbumFields tracks { id title path length albumArt } } }" 20 + ) 21 + return [Album.model_validate(a) for a in data.get("albums", [])] 22 + 23 + async def album(self, id: str) -> Album | None: 24 + data = await self._http.execute( 25 + f"{TRACK_FIELDS} {ALBUM_FIELDS} " 26 + "query Album($id: String!) " 27 + "{ album(id: $id) { ...AlbumFields tracks { ...TrackFields } } }", 28 + {"id": id}, 29 + ) 30 + raw = data.get("album") 31 + return Album.model_validate(raw) if raw is not None else None 32 + 33 + async def liked_albums(self) -> list[Album]: 34 + data = await self._http.execute( 35 + f"{ALBUM_FIELDS} query LikedAlbums {{ likedAlbums {{ ...AlbumFields }} }}" 36 + ) 37 + return [Album.model_validate(a) for a in data.get("likedAlbums", [])] 38 + 39 + async def like_album(self, id: str) -> None: 40 + await self._http.execute( 41 + "mutation LikeAlbum($id: String!) { likeAlbum(id: $id) }", {"id": id} 42 + ) 43 + 44 + async def unlike_album(self, id: str) -> None: 45 + await self._http.execute( 46 + "mutation UnlikeAlbum($id: String!) { unlikeAlbum(id: $id) }", {"id": id} 47 + ) 48 + 49 + # --- artists -------------------------------------------------------- 50 + 51 + async def artists(self) -> list[Artist]: 52 + data = await self._http.execute( 53 + f"{ARTIST_FIELDS} query Artists " 54 + "{ artists { ...ArtistFields albums { id title albumArt year } } }" 55 + ) 56 + return [Artist.model_validate(a) for a in data.get("artists", [])] 57 + 58 + async def artist(self, id: str) -> Artist | None: 59 + data = await self._http.execute( 60 + f"{ARTIST_FIELDS} {TRACK_FIELDS} " 61 + "query Artist($id: String!) { artist(id: $id) { " 62 + "...ArtistFields " 63 + "albums { id title albumArt year yearString md5 artistId " 64 + "tracks { id title path length } } " 65 + "tracks { ...TrackFields } } }", 66 + {"id": id}, 67 + ) 68 + raw = data.get("artist") 69 + return Artist.model_validate(raw) if raw is not None else None 70 + 71 + # --- tracks --------------------------------------------------------- 72 + 73 + async def tracks(self) -> list[Track]: 74 + data = await self._http.execute( 75 + f"{TRACK_FIELDS} query Tracks {{ tracks {{ ...TrackFields }} }}" 76 + ) 77 + return [Track.model_validate(t) for t in data.get("tracks", [])] 78 + 79 + async def track(self, id: str) -> Track | None: 80 + data = await self._http.execute( 81 + f"{TRACK_FIELDS} query Track($id: String!) " 82 + "{ track(id: $id) { ...TrackFields } }", 83 + {"id": id}, 84 + ) 85 + raw = data.get("track") 86 + return Track.model_validate(raw) if raw is not None else None 87 + 88 + async def liked_tracks(self) -> list[Track]: 89 + data = await self._http.execute( 90 + f"{TRACK_FIELDS} query LikedTracks {{ likedTracks {{ ...TrackFields }} }}" 91 + ) 92 + return [Track.model_validate(t) for t in data.get("likedTracks", [])] 93 + 94 + async def like_track(self, id: str) -> None: 95 + await self._http.execute( 96 + "mutation LikeTrack($id: String!) { likeTrack(id: $id) }", {"id": id} 97 + ) 98 + 99 + async def unlike_track(self, id: str) -> None: 100 + await self._http.execute( 101 + "mutation UnlikeTrack($id: String!) { unlikeTrack(id: $id) }", {"id": id} 102 + ) 103 + 104 + # --- search --------------------------------------------------------- 105 + 106 + async def search(self, term: str) -> SearchResults: 107 + data = await self._http.execute( 108 + f"{TRACK_FIELDS} {ALBUM_FIELDS} {ARTIST_FIELDS} " 109 + "query Search($term: String!) { search(term: $term) { " 110 + "artists { ...ArtistFields } " 111 + "albums { ...AlbumFields } " 112 + "tracks { ...TrackFields } " 113 + "likedTracks { ...TrackFields } " 114 + "likedAlbums { ...AlbumFields } } }", 115 + {"term": term}, 116 + ) 117 + return SearchResults.model_validate(data.get("search") or {}) 118 + 119 + # --- maintenance ---------------------------------------------------- 120 + 121 + async def scan(self) -> None: 122 + """Trigger a library rescan.""" 123 + await self._http.execute("mutation ScanLibrary { scanLibrary }")
+168
sdk/python/src/rockbox_sdk/api/playback.py
··· 1 + """Playback control: status, transport, and one-shot play helpers.""" 2 + 3 + from __future__ import annotations 4 + 5 + from typing import Any 6 + 7 + from ..transport import HttpTransport 8 + from ..types import PlaybackStatus, Track 9 + from ._fragments import TRACK_FIELDS 10 + 11 + 12 + class PlaybackApi: 13 + def __init__(self, http: HttpTransport) -> None: 14 + self._http = http 15 + 16 + # --- status --------------------------------------------------------- 17 + 18 + async def raw_status(self) -> int: 19 + """Raw numeric playback status from the firmware.""" 20 + data = await self._http.execute("query PlaybackStatus { status }") 21 + return int(data["status"]) 22 + 23 + async def status(self) -> PlaybackStatus: 24 + """Typed playback status.""" 25 + return PlaybackStatus(await self.raw_status()) 26 + 27 + async def current_track(self) -> Track | None: 28 + data = await self._http.execute( 29 + f"{TRACK_FIELDS} query CurrentTrack {{ currentTrack {{ ...TrackFields }} }}" 30 + ) 31 + raw = data.get("currentTrack") 32 + return Track.model_validate(raw) if raw is not None else None 33 + 34 + async def next_track(self) -> Track | None: 35 + data = await self._http.execute( 36 + f"{TRACK_FIELDS} query NextTrack {{ nextTrack {{ ...TrackFields }} }}" 37 + ) 38 + raw = data.get("nextTrack") 39 + return Track.model_validate(raw) if raw is not None else None 40 + 41 + async def file_position(self) -> int: 42 + data = await self._http.execute("query FilePosition { getFilePosition }") 43 + return int(data["getFilePosition"]) 44 + 45 + # --- transport ------------------------------------------------------ 46 + 47 + async def play(self, elapsed: int = 0, offset: int = 0) -> None: 48 + await self._http.execute( 49 + "mutation Play($elapsed: Long!, $offset: Long!) " 50 + "{ play(elapsed: $elapsed, offset: $offset) }", 51 + {"elapsed": elapsed, "offset": offset}, 52 + ) 53 + 54 + async def pause(self) -> None: 55 + await self._http.execute("mutation Pause { pause }") 56 + 57 + async def resume(self) -> None: 58 + await self._http.execute("mutation Resume { resume }") 59 + 60 + async def next(self) -> None: 61 + await self._http.execute("mutation Next { next }") 62 + 63 + async def previous(self) -> None: 64 + await self._http.execute("mutation Previous { previous }") 65 + 66 + async def seek(self, position_ms: int) -> None: 67 + """Seek to an absolute position in milliseconds.""" 68 + await self._http.execute( 69 + "mutation Seek($newTime: Int!) { fastForwardRewind(newTime: $newTime) }", 70 + {"newTime": position_ms}, 71 + ) 72 + 73 + async def stop(self) -> None: 74 + await self._http.execute("mutation Stop { hardStop }") 75 + 76 + async def flush_and_reload(self) -> None: 77 + """Reload and flush the current track queue.""" 78 + await self._http.execute("mutation FlushReload { flushAndReloadTracks }") 79 + 80 + # --- one-shot play helpers ----------------------------------------- 81 + 82 + async def play_track(self, path: str) -> None: 83 + await self._http.execute( 84 + "mutation PlayTrack($path: String!) { playTrack(path: $path) }", 85 + {"path": path}, 86 + ) 87 + 88 + async def play_album( 89 + self, 90 + album_id: str, 91 + *, 92 + shuffle: bool | None = None, 93 + position: int | None = None, 94 + ) -> None: 95 + await self._http.execute( 96 + "mutation PlayAlbum($albumId: String!, $shuffle: Boolean, $position: Int) " 97 + "{ playAlbum(albumId: $albumId, shuffle: $shuffle, position: $position) }", 98 + {"albumId": album_id, "shuffle": shuffle, "position": position}, 99 + ) 100 + 101 + async def play_artist( 102 + self, 103 + artist_id: str, 104 + *, 105 + shuffle: bool | None = None, 106 + position: int | None = None, 107 + ) -> None: 108 + await self._http.execute( 109 + "mutation PlayArtist($artistId: String!, $shuffle: Boolean, $position: Int) " 110 + "{ playArtistTracks(artistId: $artistId, shuffle: $shuffle, position: $position) }", 111 + {"artistId": artist_id, "shuffle": shuffle, "position": position}, 112 + ) 113 + 114 + async def play_playlist( 115 + self, 116 + playlist_id: str, 117 + *, 118 + shuffle: bool | None = None, 119 + position: int | None = None, 120 + ) -> None: 121 + await self._http.execute( 122 + "mutation PlayPlaylist($playlistId: String!, $shuffle: Boolean, $position: Int) " 123 + "{ playPlaylist(playlistId: $playlistId, shuffle: $shuffle, position: $position) }", 124 + {"playlistId": playlist_id, "shuffle": shuffle, "position": position}, 125 + ) 126 + 127 + async def play_directory( 128 + self, 129 + path: str, 130 + *, 131 + recurse: bool | None = None, 132 + shuffle: bool | None = None, 133 + position: int | None = None, 134 + ) -> None: 135 + await self._http.execute( 136 + "mutation PlayDirectory(" 137 + "$path: String!, $recurse: Boolean, $shuffle: Boolean, $position: Int" 138 + ") { playDirectory(path: $path, recurse: $recurse, " 139 + "shuffle: $shuffle, position: $position) }", 140 + {"path": path, "recurse": recurse, "shuffle": shuffle, "position": position}, 141 + ) 142 + 143 + async def play_liked_tracks( 144 + self, 145 + *, 146 + shuffle: bool | None = None, 147 + position: int | None = None, 148 + ) -> None: 149 + await self._http.execute( 150 + "mutation PlayLikedTracks($shuffle: Boolean, $position: Int) " 151 + "{ playLikedTracks(shuffle: $shuffle, position: $position) }", 152 + {"shuffle": shuffle, "position": position}, 153 + ) 154 + 155 + async def play_all_tracks( 156 + self, 157 + *, 158 + shuffle: bool | None = None, 159 + position: int | None = None, 160 + ) -> None: 161 + await self._http.execute( 162 + "mutation PlayAllTracks($shuffle: Boolean, $position: Int) " 163 + "{ playAllTracks(shuffle: $shuffle, position: $position) }", 164 + {"shuffle": shuffle, "position": position}, 165 + ) 166 + 167 + # ``Any`` to keep mypy quiet about the generic dict shape returned by GraphQL 168 + _: Any = None
+98
sdk/python/src/rockbox_sdk/api/playlist.py
··· 1 + """Active playlist (queue) management.""" 2 + 3 + from __future__ import annotations 4 + 5 + from ..transport import HttpTransport 6 + from ..types import InsertPosition, Playlist 7 + from ._fragments import TRACK_FIELDS 8 + 9 + 10 + class PlaylistApi: 11 + def __init__(self, http: HttpTransport) -> None: 12 + self._http = http 13 + 14 + async def current(self) -> Playlist: 15 + data = await self._http.execute( 16 + f"{TRACK_FIELDS} query CurrentPlaylist {{ playlistGetCurrent {{ " 17 + "amount index maxPlaylistSize firstIndex " 18 + "lastInsertPos seed lastShuffledStart " 19 + "tracks { ...TrackFields } } }}" 20 + ) 21 + return Playlist.model_validate(data["playlistGetCurrent"]) 22 + 23 + async def amount(self) -> int: 24 + data = await self._http.execute("query PlaylistAmount { playlistAmount }") 25 + return int(data["playlistAmount"]) 26 + 27 + # --- queue mutations ----------------------------------------------- 28 + 29 + async def insert_tracks( 30 + self, 31 + paths: list[str], 32 + position: InsertPosition = InsertPosition.NEXT, 33 + playlist_id: str | None = None, 34 + ) -> None: 35 + """Insert track paths or IDs into the queue.""" 36 + await self._http.execute( 37 + "mutation InsertTracks($playlistId: String, $position: Int!, $tracks: [String!]!) " 38 + "{ insertTracks(playlistId: $playlistId, position: $position, tracks: $tracks) }", 39 + {"playlistId": playlist_id, "position": int(position), "tracks": paths}, 40 + ) 41 + 42 + async def insert_directory( 43 + self, 44 + directory: str, 45 + position: InsertPosition = InsertPosition.LAST, 46 + playlist_id: str | None = None, 47 + ) -> None: 48 + await self._http.execute( 49 + "mutation InsertDirectory($playlistId: String, $position: Int!, $directory: String!) " 50 + "{ insertDirectory(playlistId: $playlistId, " 51 + "position: $position, directory: $directory) }", 52 + {"playlistId": playlist_id, "position": int(position), "directory": directory}, 53 + ) 54 + 55 + async def insert_album( 56 + self, album_id: str, position: InsertPosition = InsertPosition.LAST 57 + ) -> None: 58 + await self._http.execute( 59 + "mutation InsertAlbum($albumId: String!, $position: Int!) " 60 + "{ insertAlbum(albumId: $albumId, position: $position) }", 61 + {"albumId": album_id, "position": int(position)}, 62 + ) 63 + 64 + async def remove_track(self, index: int) -> None: 65 + await self._http.execute( 66 + "mutation RemoveTrack($index: Int!) { playlistRemoveTrack(index: $index) }", 67 + {"index": index}, 68 + ) 69 + 70 + async def clear(self) -> None: 71 + await self._http.execute("mutation ClearPlaylist { playlistRemoveAllTracks }") 72 + 73 + async def shuffle(self) -> None: 74 + await self._http.execute("mutation ShufflePlaylist { shufflePlaylist }") 75 + 76 + async def create(self, name: str, tracks: list[str]) -> None: 77 + """Create and start a new temporary playlist (replaces the current queue).""" 78 + await self._http.execute( 79 + "mutation CreatePlaylist($name: String!, $tracks: [String!]!) " 80 + "{ playlistCreate(name: $name, tracks: $tracks) }", 81 + {"name": name, "tracks": tracks}, 82 + ) 83 + 84 + async def start( 85 + self, 86 + *, 87 + start_index: int | None = None, 88 + elapsed: int | None = None, 89 + offset: int | None = None, 90 + ) -> None: 91 + await self._http.execute( 92 + "mutation PlaylistStart($startIndex: Int, $elapsed: Int, $offset: Int) " 93 + "{ playlistStart(startIndex: $startIndex, elapsed: $elapsed, offset: $offset) }", 94 + {"startIndex": start_index, "elapsed": elapsed, "offset": offset}, 95 + ) 96 + 97 + async def resume(self) -> None: 98 + await self._http.execute("mutation PlaylistResume { playlistResume }")
+143
sdk/python/src/rockbox_sdk/api/saved_playlists.py
··· 1 + """Persistent ("saved") playlists and their folders.""" 2 + 3 + from __future__ import annotations 4 + 5 + from dataclasses import asdict, dataclass 6 + 7 + from ..transport import HttpTransport 8 + from ..types import SavedPlaylist, SavedPlaylistFolder 9 + 10 + _PLAYLIST_FIELDS = "id name description image folderId trackCount createdAt updatedAt" 11 + _FOLDER_FIELDS = "id name createdAt updatedAt" 12 + 13 + 14 + @dataclass 15 + class CreatePlaylistInput: 16 + name: str 17 + description: str | None = None 18 + image: str | None = None 19 + folder_id: str | None = None 20 + track_ids: list[str] | None = None 21 + 22 + 23 + @dataclass 24 + class UpdatePlaylistInput: 25 + name: str 26 + description: str | None = None 27 + image: str | None = None 28 + folder_id: str | None = None 29 + 30 + 31 + def _camelize(d: dict[str, object]) -> dict[str, object]: 32 + """folder_id → folderId, track_ids → trackIds, etc.""" 33 + out: dict[str, object] = {} 34 + for k, v in d.items(): 35 + if v is None: 36 + continue 37 + parts = k.split("_") 38 + camel = parts[0] + "".join(p.title() for p in parts[1:]) 39 + out[camel] = v 40 + return out 41 + 42 + 43 + class SavedPlaylistsApi: 44 + def __init__(self, http: HttpTransport) -> None: 45 + self._http = http 46 + 47 + async def list(self, folder_id: str | None = None) -> list[SavedPlaylist]: 48 + data = await self._http.execute( 49 + "query SavedPlaylists($folderId: String) " 50 + f"{{ savedPlaylists(folderId: $folderId) {{ {_PLAYLIST_FIELDS} }} }}", 51 + {"folderId": folder_id}, 52 + ) 53 + return [SavedPlaylist.model_validate(p) for p in data.get("savedPlaylists", [])] 54 + 55 + async def get(self, id: str) -> SavedPlaylist | None: 56 + data = await self._http.execute( 57 + "query SavedPlaylist($id: String!) " 58 + f"{{ savedPlaylist(id: $id) {{ {_PLAYLIST_FIELDS} }} }}", 59 + {"id": id}, 60 + ) 61 + raw = data.get("savedPlaylist") 62 + return SavedPlaylist.model_validate(raw) if raw is not None else None 63 + 64 + async def track_ids(self, playlist_id: str) -> list[str]: 65 + data = await self._http.execute( 66 + "query SavedPlaylistTrackIds($playlistId: String!) " 67 + "{ savedPlaylistTrackIds(playlistId: $playlistId) }", 68 + {"playlistId": playlist_id}, 69 + ) 70 + return list(data.get("savedPlaylistTrackIds", [])) 71 + 72 + async def create(self, input: CreatePlaylistInput) -> SavedPlaylist: 73 + data = await self._http.execute( 74 + "mutation CreateSavedPlaylist(" 75 + "$name: String!, $description: String, $image: String, " 76 + "$folderId: String, $trackIds: [String!]) " 77 + "{ createSavedPlaylist(" 78 + "name: $name, description: $description, image: $image, " 79 + "folderId: $folderId, trackIds: $trackIds) " 80 + f"{{ {_PLAYLIST_FIELDS} }} }}", 81 + _camelize(asdict(input)), 82 + ) 83 + return SavedPlaylist.model_validate(data["createSavedPlaylist"]) 84 + 85 + async def update(self, id: str, input: UpdatePlaylistInput) -> None: 86 + await self._http.execute( 87 + "mutation UpdateSavedPlaylist(" 88 + "$id: String!, $name: String!, $description: String, " 89 + "$image: String, $folderId: String) " 90 + "{ updateSavedPlaylist(" 91 + "id: $id, name: $name, description: $description, " 92 + "image: $image, folderId: $folderId) }", 93 + {"id": id, **_camelize(asdict(input))}, 94 + ) 95 + 96 + async def delete(self, id: str) -> None: 97 + await self._http.execute( 98 + "mutation DeleteSavedPlaylist($id: String!) { deleteSavedPlaylist(id: $id) }", 99 + {"id": id}, 100 + ) 101 + 102 + async def add_tracks(self, playlist_id: str, track_ids: list[str]) -> None: 103 + await self._http.execute( 104 + "mutation AddTracksToSavedPlaylist($playlistId: String!, $trackIds: [String!]!) " 105 + "{ addTracksToSavedPlaylist(playlistId: $playlistId, trackIds: $trackIds) }", 106 + {"playlistId": playlist_id, "trackIds": track_ids}, 107 + ) 108 + 109 + async def remove_track(self, playlist_id: str, track_id: str) -> None: 110 + await self._http.execute( 111 + "mutation RemoveTrackFromSavedPlaylist($playlistId: String!, $trackId: String!) " 112 + "{ removeTrackFromSavedPlaylist(playlistId: $playlistId, trackId: $trackId) }", 113 + {"playlistId": playlist_id, "trackId": track_id}, 114 + ) 115 + 116 + async def play(self, playlist_id: str) -> None: 117 + await self._http.execute( 118 + "mutation PlaySavedPlaylist($playlistId: String!) " 119 + "{ playSavedPlaylist(playlistId: $playlistId) }", 120 + {"playlistId": playlist_id}, 121 + ) 122 + 123 + # --- folders -------------------------------------------------------- 124 + 125 + async def folders(self) -> list[SavedPlaylistFolder]: 126 + data = await self._http.execute( 127 + f"query PlaylistFolders {{ playlistFolders {{ {_FOLDER_FIELDS} }} }}" 128 + ) 129 + return [SavedPlaylistFolder.model_validate(f) for f in data.get("playlistFolders", [])] 130 + 131 + async def create_folder(self, name: str) -> SavedPlaylistFolder: 132 + data = await self._http.execute( 133 + "mutation CreatePlaylistFolder($name: String!) " 134 + f"{{ createPlaylistFolder(name: $name) {{ {_FOLDER_FIELDS} }} }}", 135 + {"name": name}, 136 + ) 137 + return SavedPlaylistFolder.model_validate(data["createPlaylistFolder"]) 138 + 139 + async def delete_folder(self, id: str) -> None: 140 + await self._http.execute( 141 + "mutation DeletePlaylistFolder($id: String!) { deletePlaylistFolder(id: $id) }", 142 + {"id": id}, 143 + )
+61
sdk/python/src/rockbox_sdk/api/settings.py
··· 1 + """Global user settings (EQ, replaygain, crossfade, …).""" 2 + 3 + from __future__ import annotations 4 + 5 + from typing import Any 6 + 7 + from ..transport import HttpTransport 8 + from ..types import UserSettings 9 + 10 + _SETTINGS_FIELDS = """ 11 + musicDir volume balance bass treble channelConfig stereoWidth 12 + eqEnabled eqPrecut 13 + eqBandSettings { cutoff q gain } 14 + replaygainSettings { noclip type preamp } 15 + compressorSettings { threshold makeupGain ratio knee releaseTime attackTime } 16 + crossfadeEnabled crossfadeFadeInDelay crossfadeFadeInDuration 17 + crossfadeFadeOutDelay crossfadeFadeOutDuration crossfadeFadeOutMixmode 18 + crossfeedEnabled crossfeedDirectGain crossfeedCrossGain 19 + crossfeedHfAttenuation crossfeedHfCutoff 20 + repeatMode singleMode partyMode shuffle playerName 21 + """ 22 + 23 + 24 + class SettingsApi: 25 + def __init__(self, http: HttpTransport) -> None: 26 + self._http = http 27 + 28 + async def get(self) -> UserSettings: 29 + data = await self._http.execute( 30 + f"query GlobalSettings {{ globalSettings {{ {_SETTINGS_FIELDS} }} }}" 31 + ) 32 + return UserSettings.model_validate(data["globalSettings"]) 33 + 34 + async def save(self, settings: dict[str, Any] | UserSettings) -> None: 35 + """Apply a partial update to the global settings. 36 + 37 + Accepts either a dict (snake_case keys are converted to camelCase) or a 38 + full ``UserSettings`` instance. 39 + """ 40 + if isinstance(settings, UserSettings): 41 + payload = settings.model_dump(by_alias=True, exclude_none=True) 42 + else: 43 + payload = _to_camel_keys(settings) 44 + await self._http.execute( 45 + "mutation SaveSettings($settings: NewGlobalSettings!) " 46 + "{ saveSettings(settings: $settings) }", 47 + {"settings": payload}, 48 + ) 49 + 50 + 51 + def _to_camel_keys(d: dict[str, Any]) -> dict[str, Any]: 52 + out: dict[str, Any] = {} 53 + for k, v in d.items(): 54 + parts = k.split("_") 55 + camel = parts[0] + "".join(p.title() for p in parts[1:]) if "_" in k else k 56 + if isinstance(v, dict): 57 + v = _to_camel_keys(v) 58 + elif isinstance(v, list): 59 + v = [_to_camel_keys(x) if isinstance(x, dict) else x for x in v] 60 + out[camel] = v 61 + return out
+120
sdk/python/src/rockbox_sdk/api/smart_playlists.py
··· 1 + """Smart (rule-based) playlists and listening stats.""" 2 + 3 + from __future__ import annotations 4 + 5 + from dataclasses import asdict, dataclass 6 + 7 + from ..transport import HttpTransport 8 + from ..types import SmartPlaylist, TrackStats 9 + from .saved_playlists import _camelize 10 + 11 + _SMART_FIELDS = ( 12 + "id name description image folderId isSystem rules createdAt updatedAt" 13 + ) 14 + 15 + 16 + @dataclass 17 + class CreateSmartPlaylistInput: 18 + name: str 19 + rules: str 20 + description: str | None = None 21 + image: str | None = None 22 + folder_id: str | None = None 23 + 24 + 25 + @dataclass 26 + class UpdateSmartPlaylistInput: 27 + name: str 28 + rules: str 29 + description: str | None = None 30 + image: str | None = None 31 + folder_id: str | None = None 32 + 33 + 34 + class SmartPlaylistsApi: 35 + def __init__(self, http: HttpTransport) -> None: 36 + self._http = http 37 + 38 + async def list(self) -> list[SmartPlaylist]: 39 + data = await self._http.execute( 40 + f"query SmartPlaylists {{ smartPlaylists {{ {_SMART_FIELDS} }} }}" 41 + ) 42 + return [SmartPlaylist.model_validate(p) for p in data.get("smartPlaylists", [])] 43 + 44 + async def get(self, id: str) -> SmartPlaylist | None: 45 + data = await self._http.execute( 46 + "query SmartPlaylist($id: String!) " 47 + f"{{ smartPlaylist(id: $id) {{ {_SMART_FIELDS} }} }}", 48 + {"id": id}, 49 + ) 50 + raw = data.get("smartPlaylist") 51 + return SmartPlaylist.model_validate(raw) if raw is not None else None 52 + 53 + async def track_ids(self, id: str) -> list[str]: 54 + data = await self._http.execute( 55 + "query SmartPlaylistTrackIds($id: String!) { smartPlaylistTrackIds(id: $id) }", 56 + {"id": id}, 57 + ) 58 + return list(data.get("smartPlaylistTrackIds", [])) 59 + 60 + async def create(self, input: CreateSmartPlaylistInput) -> SmartPlaylist: 61 + data = await self._http.execute( 62 + "mutation CreateSmartPlaylist(" 63 + "$name: String!, $rules: String!, $description: String, " 64 + "$image: String, $folderId: String) " 65 + "{ createSmartPlaylist(" 66 + "name: $name, rules: $rules, description: $description, " 67 + "image: $image, folderId: $folderId) " 68 + f"{{ {_SMART_FIELDS} }} }}", 69 + _camelize(asdict(input)), 70 + ) 71 + return SmartPlaylist.model_validate(data["createSmartPlaylist"]) 72 + 73 + async def update(self, id: str, input: UpdateSmartPlaylistInput) -> None: 74 + await self._http.execute( 75 + "mutation UpdateSmartPlaylist(" 76 + "$id: String!, $name: String!, $rules: String!, " 77 + "$description: String, $image: String, $folderId: String) " 78 + "{ updateSmartPlaylist(" 79 + "id: $id, name: $name, rules: $rules, description: $description, " 80 + "image: $image, folderId: $folderId) }", 81 + {"id": id, **_camelize(asdict(input))}, 82 + ) 83 + 84 + async def delete(self, id: str) -> None: 85 + await self._http.execute( 86 + "mutation DeleteSmartPlaylist($id: String!) { deleteSmartPlaylist(id: $id) }", 87 + {"id": id}, 88 + ) 89 + 90 + async def play(self, id: str) -> None: 91 + await self._http.execute( 92 + "mutation PlaySmartPlaylist($id: String!) { playSmartPlaylist(id: $id) }", 93 + {"id": id}, 94 + ) 95 + 96 + # --- listening stats ----------------------------------------------- 97 + 98 + async def track_stats(self, track_id: str) -> TrackStats | None: 99 + data = await self._http.execute( 100 + "query TrackStats($trackId: String!) " 101 + "{ trackStats(trackId: $trackId) " 102 + "{ trackId playCount skipCount lastPlayed lastSkipped updatedAt } }", 103 + {"trackId": track_id}, 104 + ) 105 + raw = data.get("trackStats") 106 + return TrackStats.model_validate(raw) if raw is not None else None 107 + 108 + async def record_played(self, track_id: str) -> None: 109 + await self._http.execute( 110 + "mutation RecordTrackPlayed($trackId: String!) " 111 + "{ recordTrackPlayed(trackId: $trackId) }", 112 + {"trackId": track_id}, 113 + ) 114 + 115 + async def record_skipped(self, track_id: str) -> None: 116 + await self._http.execute( 117 + "mutation RecordTrackSkipped($trackId: String!) " 118 + "{ recordTrackSkipped(trackId: $trackId) }", 119 + {"trackId": track_id}, 120 + )
+30
sdk/python/src/rockbox_sdk/api/sound.py
··· 1 + """Volume and audio level controls.""" 2 + 3 + from __future__ import annotations 4 + 5 + from ..transport import HttpTransport 6 + from ..types import VolumeInfo 7 + 8 + 9 + class SoundApi: 10 + def __init__(self, http: HttpTransport) -> None: 11 + self._http = http 12 + 13 + async def get_volume(self) -> VolumeInfo: 14 + """Current volume with min/max range (firmware steps, typically dB).""" 15 + data = await self._http.execute("query Volume { volume { volume min max } }") 16 + return VolumeInfo.model_validate(data["volume"]) 17 + 18 + async def adjust_volume(self, steps: int) -> int: 19 + """Adjust by a relative step count (positive = louder). Returns the new volume.""" 20 + data = await self._http.execute( 21 + "mutation AdjustVolume($steps: Int!) { adjustVolume(steps: $steps) }", 22 + {"steps": steps}, 23 + ) 24 + return int(data["adjustVolume"]) 25 + 26 + async def volume_up(self) -> int: 27 + return await self.adjust_volume(1) 28 + 29 + async def volume_down(self) -> int: 30 + return await self.adjust_volume(-1)
+24
sdk/python/src/rockbox_sdk/api/system.py
··· 1 + """System-level info: version, runtime status.""" 2 + 3 + from __future__ import annotations 4 + 5 + from ..transport import HttpTransport 6 + from ..types import SystemStatus 7 + 8 + 9 + class SystemApi: 10 + def __init__(self, http: HttpTransport) -> None: 11 + self._http = http 12 + 13 + async def version(self) -> str: 14 + data = await self._http.execute("query Version { rockboxVersion }") 15 + return str(data["rockboxVersion"]) 16 + 17 + async def status(self) -> SystemStatus: 18 + data = await self._http.execute( 19 + "query GlobalStatus { globalStatus { " 20 + "resumeIndex resumeCrc32 resumeElapsed resumeOffset " 21 + "runtime topruntime dircacheSize " 22 + "lastScreen viewerIconCount lastVolumeChange } }" 23 + ) 24 + return SystemStatus.model_validate(data["globalStatus"])
+326
sdk/python/src/rockbox_sdk/client.py
··· 1 + """``RockboxClient`` — async, top-level entry point. 2 + 3 + Inspired by the same systems as the TypeScript SDK: 4 + - Mopidy: domain-namespaced API (``client.playback.play()``, ``client.library.search()``) 5 + - Jellyfin: plugin lifecycle (install / uninstall) 6 + - Kodi: rich device & playlist management 7 + - Navidrome: clean typed search & library queries 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import asyncio 13 + from collections.abc import Awaitable, Callable 14 + from dataclasses import dataclass 15 + from types import TracebackType 16 + from typing import Any 17 + 18 + from .api import ( 19 + BluetoothApi, 20 + BrowseApi, 21 + DevicesApi, 22 + LibraryApi, 23 + PlaybackApi, 24 + PlaylistApi, 25 + SavedPlaylistsApi, 26 + SettingsApi, 27 + SmartPlaylistsApi, 28 + SoundApi, 29 + SystemApi, 30 + ) 31 + from .events import ( 32 + PLAYLIST_CHANGED, 33 + STATUS_CHANGED, 34 + TRACK_CHANGED, 35 + WS_ERROR, 36 + EventEmitter, 37 + Listener, 38 + ) 39 + from .plugin import PluginContext, PluginRegistry, RockboxPlugin 40 + from .transport import HttpTransport, WsTransport 41 + from .types import Playlist, Track 42 + 43 + 44 + @dataclass 45 + class RockboxClientConfig: 46 + """Connection settings. Use :meth:`RockboxClient.builder` for a fluent constructor.""" 47 + 48 + host: str = "localhost" 49 + """Hostname or IP of the rockboxd instance.""" 50 + 51 + port: int = 6062 52 + """GraphQL HTTP/WS port.""" 53 + 54 + http_url: str | None = None 55 + """Override the full HTTP URL. Takes precedence over ``host``/``port``.""" 56 + 57 + ws_url: str | None = None 58 + """Override the full WebSocket URL. Takes precedence over ``host``/``port``.""" 59 + 60 + timeout: float = 30.0 61 + """Per-request HTTP timeout in seconds.""" 62 + 63 + def resolve_http_url(self) -> str: 64 + return self.http_url or f"http://{self.host}:{self.port}/graphql" 65 + 66 + def resolve_ws_url(self) -> str: 67 + return self.ws_url or f"ws://{self.host}:{self.port}/graphql" 68 + 69 + 70 + class RockboxClientBuilder: 71 + """Fluent builder for :class:`RockboxClient`. 72 + 73 + Example:: 74 + 75 + client = ( 76 + RockboxClient.builder() 77 + .host("192.168.1.42") 78 + .port(6062) 79 + .timeout(15) 80 + .build() 81 + ) 82 + """ 83 + 84 + def __init__(self) -> None: 85 + self._cfg = RockboxClientConfig() 86 + 87 + def host(self, host: str) -> RockboxClientBuilder: 88 + self._cfg.host = host 89 + return self 90 + 91 + def port(self, port: int) -> RockboxClientBuilder: 92 + self._cfg.port = port 93 + return self 94 + 95 + def http_url(self, url: str) -> RockboxClientBuilder: 96 + self._cfg.http_url = url 97 + return self 98 + 99 + def ws_url(self, url: str) -> RockboxClientBuilder: 100 + self._cfg.ws_url = url 101 + return self 102 + 103 + def timeout(self, seconds: float) -> RockboxClientBuilder: 104 + self._cfg.timeout = seconds 105 + return self 106 + 107 + def build(self) -> RockboxClient: 108 + return RockboxClient(self._cfg) 109 + 110 + 111 + class RockboxClient(EventEmitter): 112 + """Async Rockbox client. 113 + 114 + Two ways to construct: 115 + 116 + # Direct keyword args: 117 + client = RockboxClient(host="localhost", port=6062) 118 + 119 + # Fluent builder: 120 + client = RockboxClient.builder().host("nas.local").build() 121 + 122 + Use as an async context manager to clean up the HTTP pool and WebSocket 123 + automatically:: 124 + 125 + async with RockboxClient() as client: 126 + await client.playback.play() 127 + """ 128 + 129 + def __init__( 130 + self, 131 + config: RockboxClientConfig | None = None, 132 + *, 133 + host: str | None = None, 134 + port: int | None = None, 135 + http_url: str | None = None, 136 + ws_url: str | None = None, 137 + timeout: float | None = None, 138 + ) -> None: 139 + super().__init__() 140 + cfg = config or RockboxClientConfig() 141 + if host is not None: 142 + cfg.host = host 143 + if port is not None: 144 + cfg.port = port 145 + if http_url is not None: 146 + cfg.http_url = http_url 147 + if ws_url is not None: 148 + cfg.ws_url = ws_url 149 + if timeout is not None: 150 + cfg.timeout = timeout 151 + self._config = cfg 152 + 153 + self._http = HttpTransport(cfg.resolve_http_url(), timeout=cfg.timeout) 154 + self._ws = WsTransport( 155 + cfg.resolve_ws_url(), 156 + on_error=self._forward_ws_error, 157 + ) 158 + self._plugins = PluginRegistry() 159 + self._unsubs: list[Callable[[], Awaitable[None]]] = [] 160 + 161 + # Domain APIs 162 + self.playback = PlaybackApi(self._http) 163 + self.library = LibraryApi(self._http) 164 + self.playlist = PlaylistApi(self._http) 165 + self.saved_playlists = SavedPlaylistsApi(self._http) 166 + self.smart_playlists = SmartPlaylistsApi(self._http) 167 + self.sound = SoundApi(self._http) 168 + self.settings = SettingsApi(self._http) 169 + self.system = SystemApi(self._http) 170 + self.browse = BrowseApi(self._http) 171 + self.devices = DevicesApi(self._http) 172 + self.bluetooth = BluetoothApi(self._http) 173 + 174 + # ---- builder & context manager ------------------------------------ 175 + 176 + @classmethod 177 + def builder(cls) -> RockboxClientBuilder: 178 + return RockboxClientBuilder() 179 + 180 + async def __aenter__(self) -> RockboxClient: 181 + return self 182 + 183 + async def __aexit__( 184 + self, 185 + exc_type: type[BaseException] | None, 186 + exc: BaseException | None, 187 + tb: TracebackType | None, 188 + ) -> None: 189 + await self.aclose() 190 + 191 + # ---- subscriptions ------------------------------------------------ 192 + 193 + async def connect(self) -> RockboxClient: 194 + """Open the WebSocket and start forwarding subscription events. 195 + 196 + Idempotent: calling twice has no effect. 197 + """ 198 + if self._unsubs: 199 + return self 200 + 201 + track_unsub = await self._ws.subscribe( 202 + """ 203 + subscription CurrentlyPlaying { 204 + currentlyPlayingSong { 205 + id title artist album albumArt albumId artistId path length elapsed 206 + } 207 + } 208 + """, 209 + None, 210 + self._on_track_payload, 211 + self._forward_ws_error, 212 + ) 213 + 214 + status_unsub = await self._ws.subscribe( 215 + "subscription PlaybackStatus { playbackStatus { status } }", 216 + None, 217 + self._on_status_payload, 218 + self._forward_ws_error, 219 + ) 220 + 221 + playlist_unsub = await self._ws.subscribe( 222 + """ 223 + subscription PlaylistChanged { 224 + playlistChanged { 225 + amount index maxPlaylistSize firstIndex lastInsertPos 226 + seed lastShuffledStart 227 + tracks { id title artist album path length albumArt } 228 + } 229 + } 230 + """, 231 + None, 232 + self._on_playlist_payload, 233 + self._forward_ws_error, 234 + ) 235 + 236 + self._unsubs = [track_unsub, status_unsub, playlist_unsub] 237 + return self 238 + 239 + async def disconnect(self) -> None: 240 + """Tear down all subscriptions and close the WebSocket.""" 241 + for unsub in self._unsubs: 242 + try: 243 + await unsub() 244 + except Exception: # noqa: BLE001 245 + pass 246 + self._unsubs = [] 247 + await self._ws.aclose() 248 + 249 + async def aclose(self) -> None: 250 + """Close all transports. Use this (or the context manager) at shutdown.""" 251 + await self.disconnect() 252 + await self._http.aclose() 253 + 254 + # ---- payload handlers -------------------------------------------- 255 + 256 + async def _on_track_payload(self, payload: dict[str, Any]) -> None: 257 + data = (payload.get("data") or {}).get("currentlyPlayingSong") 258 + if data is not None: 259 + await self.emit(TRACK_CHANGED, Track.model_validate(data)) 260 + 261 + async def _on_status_payload(self, payload: dict[str, Any]) -> None: 262 + data = (payload.get("data") or {}).get("playbackStatus") 263 + if data is not None and "status" in data: 264 + await self.emit(STATUS_CHANGED, int(data["status"])) 265 + 266 + async def _on_playlist_payload(self, payload: dict[str, Any]) -> None: 267 + data = (payload.get("data") or {}).get("playlistChanged") 268 + if data is not None: 269 + await self.emit(PLAYLIST_CHANGED, Playlist.model_validate(data)) 270 + 271 + async def _forward_ws_error(self, err: Exception) -> None: 272 + await self.emit(WS_ERROR, err) 273 + 274 + # ---- ergonomic event helpers (typed convenience) ----------------- 275 + 276 + def on_track_changed(self, listener: Listener) -> RockboxClient: 277 + self.on(TRACK_CHANGED, listener) 278 + return self 279 + 280 + def on_status_changed(self, listener: Listener) -> RockboxClient: 281 + self.on(STATUS_CHANGED, listener) 282 + return self 283 + 284 + def on_playlist_changed(self, listener: Listener) -> RockboxClient: 285 + self.on(PLAYLIST_CHANGED, listener) 286 + return self 287 + 288 + # ---- plugins ------------------------------------------------------ 289 + 290 + async def use(self, plugin: RockboxPlugin) -> RockboxClient: 291 + """Register a plugin. Plugins are identified by their ``name``.""" 292 + await self._plugins.register( 293 + plugin, 294 + PluginContext(query=self.query, events=self), 295 + ) 296 + return self 297 + 298 + async def unuse(self, name: str) -> RockboxClient: 299 + await self._plugins.unregister(name) 300 + return self 301 + 302 + def installed_plugins(self) -> list[RockboxPlugin]: 303 + return self._plugins.list() 304 + 305 + # ---- raw escape hatch -------------------------------------------- 306 + 307 + async def query(self, gql: str, variables: dict[str, Any] | None = None) -> Any: 308 + """Run an arbitrary GraphQL operation and return ``data``.""" 309 + return await self._http.execute(gql, variables) 310 + 311 + # ---- blocking helpers -------------------------------------------- 312 + 313 + def run(self, coro: Awaitable[Any]) -> Any: 314 + """Helper for sync scripts: ``client.run(client.playback.play())``. 315 + 316 + This is just ``asyncio.run(...)``; useful in REPLs and short scripts. 317 + It cannot be called from inside an already-running event loop. 318 + """ 319 + try: 320 + asyncio.get_running_loop() 321 + except RuntimeError: 322 + return asyncio.run(coro) # type: ignore[arg-type] 323 + raise RuntimeError( 324 + "RockboxClient.run() cannot be called from a running event loop; " 325 + "await the coroutine directly." 326 + )
+26
sdk/python/src/rockbox_sdk/errors.py
··· 1 + """Exception hierarchy for the Rockbox SDK.""" 2 + 3 + from __future__ import annotations 4 + 5 + from typing import Any 6 + 7 + 8 + class RockboxError(Exception): 9 + """Base error for everything the SDK raises.""" 10 + 11 + def __init__(self, message: str, cause: Exception | None = None) -> None: 12 + super().__init__(message) 13 + self.message = message 14 + self.cause = cause 15 + 16 + 17 + class RockboxNetworkError(RockboxError): 18 + """Raised when the SDK cannot reach the rockboxd HTTP/WS endpoint.""" 19 + 20 + 21 + class RockboxGraphQLError(RockboxError): 22 + """Raised when the server returns a non-empty `errors` array.""" 23 + 24 + def __init__(self, errors: list[dict[str, Any]]) -> None: 25 + self.errors = errors 26 + super().__init__("; ".join(str(e.get("message", e)) for e in errors))
+124
sdk/python/src/rockbox_sdk/events.py
··· 1 + """Lightweight async event emitter used by ``RockboxClient``. 2 + 3 + Listeners may be sync (``def``) or async (``async def``); the emitter awaits 4 + the latter and calls the former synchronously. There is no Node-style 5 + ``EventEmitter`` dependency. 6 + """ 7 + 8 + from __future__ import annotations 9 + 10 + import asyncio 11 + import inspect 12 + from collections import defaultdict 13 + from collections.abc import Awaitable, Callable 14 + from typing import Any, TypeAlias 15 + 16 + # Public event names emitted by the client. 17 + EventName: TypeAlias = str 18 + 19 + #: Event names emitted by the SDK. Importing as constants keeps editors honest. 20 + TRACK_CHANGED: EventName = "track:changed" 21 + """Fires whenever the currently playing track changes. Payload: ``Track``.""" 22 + 23 + STATUS_CHANGED: EventName = "status:changed" 24 + """Fires when playback status changes. Payload: ``int`` (raw firmware status).""" 25 + 26 + PLAYLIST_CHANGED: EventName = "playlist:changed" 27 + """Fires when the active playlist changes. Payload: ``Playlist``.""" 28 + 29 + WS_OPEN: EventName = "ws:open" 30 + """WebSocket connection opened. No payload.""" 31 + 32 + WS_CLOSE: EventName = "ws:close" 33 + """WebSocket connection closed. No payload.""" 34 + 35 + WS_ERROR: EventName = "ws:error" 36 + """WebSocket or subscription error. Payload: ``Exception``.""" 37 + 38 + 39 + Listener: TypeAlias = Callable[..., Any] | Callable[..., Awaitable[Any]] 40 + 41 + 42 + class EventEmitter: 43 + """Async-aware event emitter. 44 + 45 + Usage: 46 + emitter = EventEmitter() 47 + emitter.on("track:changed", lambda t: print(t.title)) 48 + 49 + @emitter.on("track:changed") 50 + async def on_track(track): 51 + ... 52 + """ 53 + 54 + def __init__(self) -> None: 55 + self._listeners: dict[str, list[Listener]] = defaultdict(list) 56 + 57 + def on(self, event: str, listener: Listener | None = None) -> Any: 58 + """Register a listener. Usable as a decorator when ``listener`` is omitted.""" 59 + if listener is None: 60 + 61 + def decorator(fn: Listener) -> Listener: 62 + self._listeners[event].append(fn) 63 + return fn 64 + 65 + return decorator 66 + self._listeners[event].append(listener) 67 + return self 68 + 69 + def once(self, event: str, listener: Listener | None = None) -> Any: 70 + """Register a listener that fires at most once.""" 71 + 72 + def wrap(fn: Listener) -> Listener: 73 + async def wrapper(*args: Any, **kwargs: Any) -> None: 74 + self.off(event, wrapper) 75 + result = fn(*args, **kwargs) 76 + if inspect.isawaitable(result): 77 + await result 78 + 79 + self._listeners[event].append(wrapper) 80 + return fn 81 + 82 + if listener is None: 83 + return wrap 84 + wrap(listener) 85 + return self 86 + 87 + def off(self, event: str, listener: Listener) -> EventEmitter: 88 + """Remove a listener. No-op if it isn't registered.""" 89 + try: 90 + self._listeners[event].remove(listener) 91 + except ValueError: 92 + pass 93 + return self 94 + 95 + def remove_all_listeners(self, event: str | None = None) -> EventEmitter: 96 + """Drop every listener, or every listener for ``event`` if specified.""" 97 + if event is None: 98 + self._listeners.clear() 99 + else: 100 + self._listeners.pop(event, None) 101 + return self 102 + 103 + async def emit(self, event: str, *args: Any) -> None: 104 + """Notify every listener for ``event``. 105 + 106 + Async listeners are awaited concurrently. Exceptions in any listener are 107 + re-raised after every other listener has been scheduled — they do not 108 + prevent other listeners from running. 109 + """ 110 + listeners = list(self._listeners.get(event, ())) 111 + if not listeners: 112 + return 113 + 114 + coros: list[Awaitable[Any]] = [] 115 + for fn in listeners: 116 + try: 117 + result = fn(*args) 118 + except Exception: 119 + continue 120 + if inspect.isawaitable(result): 121 + coros.append(result) 122 + 123 + if coros: 124 + await asyncio.gather(*coros, return_exceptions=True)
+71
sdk/python/src/rockbox_sdk/plugin.py
··· 1 + """Plugin protocol — Jellyfin-style install/uninstall lifecycle.""" 2 + 3 + from __future__ import annotations 4 + 5 + from collections.abc import Awaitable 6 + from dataclasses import dataclass 7 + from typing import Any, Protocol, runtime_checkable 8 + 9 + from .events import EventEmitter 10 + 11 + 12 + @dataclass 13 + class PluginContext: 14 + """Handed to a plugin's :meth:`install`. Lets it issue raw GraphQL and listen for events.""" 15 + 16 + query: QueryFn 17 + events: EventEmitter 18 + 19 + 20 + class QueryFn(Protocol): 21 + async def __call__( 22 + self, gql: str, variables: dict[str, Any] | None = None, / 23 + ) -> Any: ... 24 + 25 + 26 + @runtime_checkable 27 + class RockboxPlugin(Protocol): 28 + """Anything implementing this Protocol can be loaded with :meth:`RockboxClient.use`.""" 29 + 30 + name: str 31 + version: str 32 + description: str | None 33 + 34 + def install( 35 + self, context: PluginContext 36 + ) -> None | Awaitable[None]: ... 37 + 38 + def uninstall(self) -> None | Awaitable[None]: # type: ignore[empty-body] 39 + ... 40 + 41 + 42 + class PluginRegistry: 43 + """Tracks installed plugins by ``name``. Names must be unique within a client.""" 44 + 45 + def __init__(self) -> None: 46 + self._plugins: dict[str, RockboxPlugin] = {} 47 + 48 + async def register(self, plugin: RockboxPlugin, context: PluginContext) -> None: 49 + if plugin.name in self._plugins: 50 + raise ValueError(f"Plugin {plugin.name!r} is already installed") 51 + result = plugin.install(context) 52 + if hasattr(result, "__await__"): 53 + await result # type: ignore[misc] 54 + self._plugins[plugin.name] = plugin 55 + 56 + async def unregister(self, name: str) -> None: 57 + plugin = self._plugins.pop(name, None) 58 + if plugin is None: 59 + return 60 + uninstall = getattr(plugin, "uninstall", None) 61 + if uninstall is None: 62 + return 63 + result = uninstall() 64 + if hasattr(result, "__await__"): 65 + await result 66 + 67 + def has(self, name: str) -> bool: 68 + return name in self._plugins 69 + 70 + def list(self) -> list[RockboxPlugin]: 71 + return list(self._plugins.values())
sdk/python/src/rockbox_sdk/py.typed

This is a binary file and will not be displayed.

+331
sdk/python/src/rockbox_sdk/transport.py
··· 1 + """HTTP and WebSocket transport for GraphQL. 2 + 3 + ``HttpTransport`` is a thin wrapper around ``httpx.AsyncClient`` — one POST per 4 + operation, no caching. ``WsTransport`` implements the 5 + `graphql-transport-ws <https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md>`_ 6 + sub-protocol on top of ``websockets``, with infinite reconnects and exponential 7 + backoff. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import asyncio 13 + import json 14 + import logging 15 + from collections.abc import AsyncIterator, Awaitable, Callable 16 + from typing import Any 17 + 18 + import httpx 19 + import websockets 20 + import websockets.exceptions 21 + 22 + from .errors import RockboxGraphQLError, RockboxNetworkError 23 + 24 + # Type-only — works for websockets >= 11 25 + try: 26 + from websockets.asyncio.client import ( 27 + ClientConnection as _WsConn, # type: ignore[import-not-found] 28 + ) 29 + except ImportError: # pragma: no cover - very old websockets 30 + from websockets.client import _WsConn as _WsConn # type: ignore[no-redef] 31 + 32 + logger = logging.getLogger(__name__) 33 + 34 + 35 + # --------------------------------------------------------------------------- 36 + # HTTP transport 37 + # --------------------------------------------------------------------------- 38 + 39 + 40 + class HttpTransport: 41 + """Async GraphQL-over-HTTP transport. 42 + 43 + The client is created lazily on first use and reused for the lifetime of 44 + the transport. Call :meth:`aclose` (or use the parent client's context 45 + manager) to release the underlying connection pool. 46 + """ 47 + 48 + def __init__(self, url: str, *, timeout: float = 30.0) -> None: 49 + self.url = url 50 + self._timeout = timeout 51 + self._client: httpx.AsyncClient | None = None 52 + 53 + def _ensure_client(self) -> httpx.AsyncClient: 54 + if self._client is None: 55 + self._client = httpx.AsyncClient(timeout=self._timeout) 56 + return self._client 57 + 58 + async def execute( 59 + self, 60 + query: str, 61 + variables: dict[str, Any] | None = None, 62 + ) -> Any: 63 + """Execute a single GraphQL operation and return the ``data`` payload.""" 64 + client = self._ensure_client() 65 + try: 66 + response = await client.post( 67 + self.url, 68 + json={"query": query, "variables": variables or {}}, 69 + headers={"Content-Type": "application/json", "Accept": "application/json"}, 70 + ) 71 + except httpx.HTTPError as err: 72 + raise RockboxNetworkError(f"Failed to reach Rockbox at {self.url}", err) from err 73 + 74 + if response.status_code >= 400: 75 + raise RockboxNetworkError( 76 + f"HTTP {response.status_code} {response.reason_phrase}" 77 + ) 78 + 79 + try: 80 + payload = response.json() 81 + except json.JSONDecodeError as err: 82 + raise RockboxNetworkError("Server returned non-JSON response", err) from err 83 + 84 + errors = payload.get("errors") or [] 85 + if errors: 86 + raise RockboxGraphQLError(errors) 87 + return payload.get("data") or {} 88 + 89 + async def aclose(self) -> None: 90 + if self._client is not None: 91 + await self._client.aclose() 92 + self._client = None 93 + 94 + 95 + # --------------------------------------------------------------------------- 96 + # WebSocket transport — graphql-transport-ws subprotocol 97 + # --------------------------------------------------------------------------- 98 + 99 + 100 + SubscriptionSink = Callable[[dict[str, Any]], Awaitable[None] | None] 101 + ErrorSink = Callable[[Exception], Awaitable[None] | None] 102 + 103 + 104 + def _is_closed(ws: Any) -> bool: 105 + """Compatibility shim — both legacy (.closed) and asyncio (.state) APIs.""" 106 + if hasattr(ws, "state"): 107 + # websockets >= 13: state is an enum, OPEN/CONNECTING are not closed 108 + from websockets.protocol import State 109 + 110 + return ws.state not in (State.OPEN, State.CONNECTING) 111 + return bool(getattr(ws, "closed", False)) 112 + 113 + 114 + class WsTransport: 115 + """Lazy, auto-reconnecting GraphQL subscription client. 116 + 117 + The connection is established on the first ``subscribe`` call and shared 118 + across all active subscriptions. On disconnect the transport reconnects 119 + with exponential backoff and re-issues every still-active subscription. 120 + """ 121 + 122 + PROTOCOL = "graphql-transport-ws" 123 + 124 + def __init__( 125 + self, 126 + url: str, 127 + *, 128 + on_open: Callable[[], Awaitable[None] | None] | None = None, 129 + on_close: Callable[[], Awaitable[None] | None] | None = None, 130 + on_error: ErrorSink | None = None, 131 + ) -> None: 132 + self.url = url 133 + self._on_open = on_open 134 + self._on_close = on_close 135 + self._on_error = on_error 136 + 137 + self._ws: Any | None = None 138 + self._reader_task: asyncio.Task[None] | None = None 139 + self._connect_lock = asyncio.Lock() 140 + self._next_id = 0 141 + self._subs: dict[str, _Subscription] = {} 142 + self._closed = False 143 + 144 + async def subscribe( 145 + self, 146 + query: str, 147 + variables: dict[str, Any] | None, 148 + on_next: SubscriptionSink, 149 + on_error: ErrorSink | None = None, 150 + ) -> Callable[[], Awaitable[None]]: 151 + """Start a subscription and return a coroutine that cancels it.""" 152 + await self._ensure_connected() 153 + 154 + sub_id = str(self._next_id) 155 + self._next_id += 1 156 + 157 + sub = _Subscription( 158 + id=sub_id, 159 + query=query, 160 + variables=variables or {}, 161 + on_next=on_next, 162 + on_error=on_error or self._on_error, 163 + ) 164 + self._subs[sub_id] = sub 165 + await self._send_subscribe(sub) 166 + 167 + async def unsubscribe() -> None: 168 + self._subs.pop(sub_id, None) 169 + if self._ws is not None and not _is_closed(self._ws): 170 + try: 171 + await self._ws.send(json.dumps({"id": sub_id, "type": "complete"})) 172 + except Exception: # noqa: BLE001 173 + pass 174 + 175 + return unsubscribe 176 + 177 + async def aclose(self) -> None: 178 + self._closed = True 179 + self._subs.clear() 180 + if self._reader_task is not None: 181 + self._reader_task.cancel() 182 + try: 183 + await self._reader_task 184 + except (asyncio.CancelledError, Exception): # noqa: BLE001 185 + pass 186 + self._reader_task = None 187 + if self._ws is not None: 188 + try: 189 + await self._ws.close() 190 + except Exception: # noqa: BLE001 191 + pass 192 + self._ws = None 193 + 194 + # ---- internals ------------------------------------------------------ 195 + 196 + async def _ensure_connected(self) -> None: 197 + if self._closed: 198 + raise RuntimeError("WsTransport has been closed") 199 + async with self._connect_lock: 200 + if self._ws is not None and not _is_closed(self._ws): 201 + return 202 + await self._connect_once() 203 + 204 + async def _connect_once(self) -> None: 205 + try: 206 + ws = await websockets.connect(self.url, subprotocols=[self.PROTOCOL]) 207 + except Exception as err: # noqa: BLE001 208 + raise RockboxNetworkError(f"Failed to open WebSocket to {self.url}", err) from err 209 + 210 + await ws.send(json.dumps({"type": "connection_init"})) 211 + ack = json.loads(await ws.recv()) 212 + if ack.get("type") != "connection_ack": 213 + await ws.close() 214 + raise RockboxNetworkError(f"WebSocket handshake failed: {ack!r}") 215 + 216 + self._ws = ws 217 + await self._fire(self._on_open) 218 + self._reader_task = asyncio.create_task(self._reader_loop(ws)) 219 + 220 + async def _reader_loop(self, ws: Any) -> None: 221 + try: 222 + async for raw in ws: 223 + try: 224 + msg = json.loads(raw) 225 + except json.JSONDecodeError: 226 + continue 227 + await self._dispatch(msg) 228 + except (websockets.exceptions.ConnectionClosed, ConnectionError) as err: 229 + if self._closed: 230 + return 231 + await self._fire(self._on_close) 232 + await self._reconnect_with_backoff(err) 233 + except Exception as err: # noqa: BLE001 234 + await self._fire_error(err) 235 + 236 + async def _dispatch(self, msg: dict[str, Any]) -> None: 237 + msg_type = msg.get("type") 238 + sub_id = msg.get("id") 239 + sub = self._subs.get(sub_id) if sub_id is not None else None 240 + 241 + if msg_type == "next" and sub is not None: 242 + payload = msg.get("payload") or {} 243 + try: 244 + result = sub.on_next(payload) 245 + if asyncio.iscoroutine(result): 246 + await result 247 + except Exception as err: # noqa: BLE001 248 + if sub.on_error is not None: 249 + await self._fire_error(err, sub.on_error) 250 + elif msg_type == "error" and sub is not None: 251 + err = RockboxGraphQLError(msg.get("payload") or []) 252 + if sub.on_error is not None: 253 + await self._fire_error(err, sub.on_error) 254 + elif msg_type == "complete" and sub_id is not None: 255 + self._subs.pop(sub_id, None) 256 + 257 + async def _send_subscribe(self, sub: _Subscription) -> None: 258 + assert self._ws is not None 259 + await self._ws.send( 260 + json.dumps( 261 + { 262 + "id": sub.id, 263 + "type": "subscribe", 264 + "payload": {"query": sub.query, "variables": sub.variables}, 265 + } 266 + ) 267 + ) 268 + 269 + async def _reconnect_with_backoff(self, _err: Exception) -> None: 270 + attempt = 0 271 + while not self._closed: 272 + delay = min(2**attempt, 30) 273 + await asyncio.sleep(delay) 274 + attempt += 1 275 + try: 276 + await self._connect_once() 277 + except Exception as err: # noqa: BLE001 278 + await self._fire_error(err) 279 + continue 280 + # Resubscribe everything we still care about 281 + for sub in list(self._subs.values()): 282 + try: 283 + await self._send_subscribe(sub) 284 + except Exception as err: # noqa: BLE001 285 + await self._fire_error(err) 286 + return 287 + 288 + @staticmethod 289 + async def _fire(cb: Callable[[], Awaitable[None] | None] | None) -> None: 290 + if cb is None: 291 + return 292 + try: 293 + res = cb() 294 + if asyncio.iscoroutine(res): 295 + await res 296 + except Exception: # noqa: BLE001 297 + logger.exception("event callback raised") 298 + 299 + async def _fire_error(self, err: Exception, sink: ErrorSink | None = None) -> None: 300 + target = sink or self._on_error 301 + if target is None: 302 + logger.warning("ws transport error: %s", err) 303 + return 304 + try: 305 + res = target(err) 306 + if asyncio.iscoroutine(res): 307 + await res 308 + except Exception: # noqa: BLE001 309 + logger.exception("error callback raised") 310 + 311 + 312 + class _Subscription: 313 + __slots__ = ("id", "query", "variables", "on_next", "on_error") 314 + 315 + def __init__( 316 + self, 317 + id: str, 318 + query: str, 319 + variables: dict[str, Any], 320 + on_next: SubscriptionSink, 321 + on_error: ErrorSink | None, 322 + ) -> None: 323 + self.id = id 324 + self.query = query 325 + self.variables = variables 326 + self.on_next = on_next 327 + self.on_error = on_error 328 + 329 + 330 + # Re-exported for type checkers — kept out of the public package surface 331 + __all__ = ["HttpTransport", "WsTransport", "AsyncIterator"]
+328
sdk/python/src/rockbox_sdk/types.py
··· 1 + """Pydantic models and enums mirroring the Rockbox GraphQL schema. 2 + 3 + The server replies in camelCase. Models accept both camelCase (the wire format) and 4 + snake_case (the Python attribute names) thanks to ``populate_by_name=True`` plus 5 + ``alias_generator=to_camel``. That gives users full snake_case ergonomics on the 6 + Python side without losing fidelity with the wire protocol. 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + from enum import IntEnum 12 + from typing import Any 13 + 14 + from pydantic import BaseModel, ConfigDict 15 + from pydantic.alias_generators import to_camel 16 + 17 + # --------------------------------------------------------------------------- 18 + # Enums — values match the firmware 19 + # --------------------------------------------------------------------------- 20 + 21 + 22 + class PlaybackStatus(IntEnum): 23 + STOPPED = 0 24 + PLAYING = 1 25 + PAUSED = 3 26 + 27 + 28 + class RepeatMode(IntEnum): 29 + OFF = 0 30 + ALL = 1 31 + ONE = 2 32 + SHUFFLE = 3 33 + AB_REPEAT = 4 34 + 35 + 36 + class ChannelConfig(IntEnum): 37 + STEREO = 0 38 + STEREO_NARROW = 1 39 + MONO = 2 40 + LEFT_MIX = 3 41 + RIGHT_MIX = 4 42 + KARAOKE = 5 43 + 44 + 45 + class ReplaygainType(IntEnum): 46 + TRACK = 0 47 + ALBUM = 1 48 + SHUFFLE = 2 49 + 50 + 51 + class InsertPosition(IntEnum): 52 + """Where to drop tracks when inserting into the queue (Kodi/Mopidy-style).""" 53 + 54 + NEXT = 0 55 + """After the currently playing track.""" 56 + AFTER_CURRENT = 1 57 + """After the last manually inserted track.""" 58 + LAST = 2 59 + """At the end of the playlist.""" 60 + FIRST = 3 61 + """Replace the entire playlist.""" 62 + 63 + 64 + # --------------------------------------------------------------------------- 65 + # Base model — camelCase wire ↔ snake_case Python 66 + # --------------------------------------------------------------------------- 67 + 68 + 69 + class _Model(BaseModel): 70 + model_config = ConfigDict( 71 + alias_generator=to_camel, 72 + populate_by_name=True, 73 + extra="ignore", 74 + ) 75 + 76 + 77 + # --------------------------------------------------------------------------- 78 + # Audio types 79 + # --------------------------------------------------------------------------- 80 + 81 + 82 + class Track(_Model): 83 + id: str | None = None 84 + title: str = "" 85 + artist: str = "" 86 + album: str = "" 87 + genre: str = "" 88 + disc: str = "" 89 + track_string: str = "" 90 + year_string: str = "" 91 + composer: str = "" 92 + comment: str = "" 93 + album_artist: str = "" 94 + grouping: str = "" 95 + discnum: int = 0 96 + tracknum: int = 0 97 + layer: int = 0 98 + year: int = 0 99 + bitrate: int = 0 100 + frequency: int = 0 101 + filesize: int = 0 102 + length: int = 0 103 + """Duration in milliseconds.""" 104 + elapsed: int = 0 105 + """Current playback position in milliseconds.""" 106 + path: str = "" 107 + album_id: str | None = None 108 + artist_id: str | None = None 109 + genre_id: str | None = None 110 + album_art: str | None = None 111 + 112 + 113 + class Album(_Model): 114 + id: str 115 + title: str 116 + artist: str 117 + year: int = 0 118 + year_string: str = "" 119 + album_art: str | None = None 120 + md5: str = "" 121 + artist_id: str = "" 122 + copyright_message: str | None = None 123 + tracks: list[Track] = [] 124 + 125 + 126 + class Artist(_Model): 127 + id: str 128 + name: str 129 + bio: str | None = None 130 + image: str | None = None 131 + tracks: list[Track] = [] 132 + albums: list[Album] = [] 133 + 134 + 135 + class SearchResults(_Model): 136 + artists: list[Artist] = [] 137 + albums: list[Album] = [] 138 + tracks: list[Track] = [] 139 + liked_tracks: list[Track] = [] 140 + liked_albums: list[Album] = [] 141 + 142 + 143 + # --------------------------------------------------------------------------- 144 + # Playlist types 145 + # --------------------------------------------------------------------------- 146 + 147 + 148 + class Playlist(_Model): 149 + amount: int 150 + index: int 151 + max_playlist_size: int = 0 152 + first_index: int = 0 153 + last_insert_pos: int = 0 154 + seed: int = 0 155 + last_shuffled_start: int = 0 156 + tracks: list[Track] = [] 157 + 158 + 159 + class SavedPlaylist(_Model): 160 + id: str 161 + name: str 162 + description: str | None = None 163 + image: str | None = None 164 + folder_id: str | None = None 165 + track_count: int = 0 166 + created_at: int = 0 167 + updated_at: int = 0 168 + 169 + 170 + class SavedPlaylistFolder(_Model): 171 + id: str 172 + name: str 173 + created_at: int = 0 174 + updated_at: int = 0 175 + 176 + 177 + class SmartPlaylist(_Model): 178 + id: str 179 + name: str 180 + description: str | None = None 181 + image: str | None = None 182 + folder_id: str | None = None 183 + is_system: bool = False 184 + rules: str = "" 185 + """JSON-encoded rules string.""" 186 + created_at: int = 0 187 + updated_at: int = 0 188 + 189 + 190 + class TrackStats(_Model): 191 + track_id: str 192 + play_count: int = 0 193 + skip_count: int = 0 194 + last_played: int | None = None 195 + last_skipped: int | None = None 196 + updated_at: int = 0 197 + 198 + 199 + # --------------------------------------------------------------------------- 200 + # Bluetooth, sound, devices, browse 201 + # --------------------------------------------------------------------------- 202 + 203 + 204 + class BluetoothDevice(_Model): 205 + address: str 206 + name: str 207 + paired: bool = False 208 + trusted: bool = False 209 + connected: bool = False 210 + rssi: int | None = None 211 + 212 + 213 + class VolumeInfo(_Model): 214 + volume: int 215 + min: int 216 + max: int 217 + 218 + 219 + class Device(_Model): 220 + id: str 221 + name: str 222 + host: str = "" 223 + ip: str = "" 224 + port: int = 0 225 + service: str = "" 226 + app: str = "" 227 + is_connected: bool = False 228 + base_url: str | None = None 229 + is_cast_device: bool = False 230 + is_source_device: bool = False 231 + is_current_device: bool = False 232 + 233 + 234 + class Entry(_Model): 235 + name: str 236 + attr: int 237 + """Bitmask: bit 4 (0x10) = directory.""" 238 + time_write: int = 0 239 + customaction: int = 0 240 + display_name: str | None = None 241 + 242 + @property 243 + def is_directory(self) -> bool: 244 + return (self.attr & 0x10) != 0 245 + 246 + 247 + def is_directory(entry: Entry) -> bool: 248 + """Mirror of the TypeScript ``isDirectory`` helper.""" 249 + return entry.is_directory 250 + 251 + 252 + # --------------------------------------------------------------------------- 253 + # System & settings 254 + # --------------------------------------------------------------------------- 255 + 256 + 257 + class SystemStatus(_Model): 258 + resume_index: int = 0 259 + resume_crc32: int = 0 260 + resume_elapsed: int = 0 261 + resume_offset: int = 0 262 + runtime: int = 0 263 + topruntime: int = 0 264 + dircache_size: int = 0 265 + last_screen: int = 0 266 + viewer_icon_count: int = 0 267 + last_volume_change: int = 0 268 + 269 + 270 + class EqBandSetting(_Model): 271 + cutoff: int 272 + q: int 273 + gain: int 274 + 275 + 276 + class ReplaygainSettings(_Model): 277 + noclip: bool = False 278 + type: int = 0 279 + preamp: int = 0 280 + 281 + 282 + class CompressorSettings(_Model): 283 + threshold: int = 0 284 + makeup_gain: int = 0 285 + ratio: int = 0 286 + knee: int = 0 287 + release_time: int = 0 288 + attack_time: int = 0 289 + 290 + 291 + class UserSettings(_Model): 292 + """Server-side settings as returned by ``globalSettings``. 293 + 294 + Tolerant of new fields the server might add — anything unknown is dropped. 295 + """ 296 + 297 + music_dir: str = "" 298 + volume: int = 0 299 + balance: int = 0 300 + bass: int = 0 301 + treble: int = 0 302 + channel_config: int = 0 303 + stereo_width: int = 0 304 + eq_enabled: bool = False 305 + eq_precut: int = 0 306 + eq_band_settings: list[EqBandSetting] = [] 307 + replaygain_settings: ReplaygainSettings = ReplaygainSettings() 308 + compressor_settings: CompressorSettings = CompressorSettings() 309 + crossfade_enabled: int = 0 310 + crossfade_fade_in_delay: int = 0 311 + crossfade_fade_in_duration: int = 0 312 + crossfade_fade_out_delay: int = 0 313 + crossfade_fade_out_duration: int = 0 314 + crossfade_fade_out_mixmode: int = 0 315 + crossfeed_enabled: bool = False 316 + crossfeed_direct_gain: int = 0 317 + crossfeed_cross_gain: int = 0 318 + crossfeed_hf_attenuation: int = 0 319 + crossfeed_hf_cutoff: int = 0 320 + repeat_mode: int = 0 321 + single_mode: bool = False 322 + party_mode: bool = False 323 + shuffle: bool = False 324 + player_name: str = "" 325 + 326 + 327 + # Used as input to settings.save() — accepts any partial subset of UserSettings 328 + PartialUserSettings = dict[str, Any]
sdk/python/tests/__init__.py

This is a binary file and will not be displayed.

+134
sdk/python/tests/test_client.py
··· 1 + """Tests for the top-level client and HTTP transport. 2 + 3 + Uses ``pytest-httpx`` to mock the GraphQL endpoint — no live rockboxd needed. 4 + """ 5 + 6 + from __future__ import annotations 7 + 8 + import pytest 9 + from pytest_httpx import HTTPXMock 10 + 11 + from rockbox_sdk import ( 12 + InsertPosition, 13 + PlaybackStatus, 14 + RockboxClient, 15 + RockboxGraphQLError, 16 + RockboxNetworkError, 17 + ) 18 + 19 + 20 + def _ok(data: dict) -> dict: 21 + return {"data": data} 22 + 23 + 24 + @pytest.mark.asyncio 25 + async def test_builder_sets_urls() -> None: 26 + client = RockboxClient.builder().host("nas.local").port(1234).build() 27 + assert client._config.resolve_http_url() == "http://nas.local:1234/graphql" 28 + assert client._config.resolve_ws_url() == "ws://nas.local:1234/graphql" 29 + await client.aclose() 30 + 31 + 32 + @pytest.mark.asyncio 33 + async def test_full_url_overrides(httpx_mock: HTTPXMock) -> None: 34 + httpx_mock.add_response(json=_ok({"status": 1}), url="http://override/graphql") 35 + async with RockboxClient(http_url="http://override/graphql") as client: 36 + assert await client.playback.status() == PlaybackStatus.PLAYING 37 + 38 + 39 + @pytest.mark.asyncio 40 + async def test_status_typed(httpx_mock: HTTPXMock) -> None: 41 + httpx_mock.add_response(json=_ok({"status": 3})) 42 + async with RockboxClient() as client: 43 + assert await client.playback.status() == PlaybackStatus.PAUSED 44 + 45 + 46 + @pytest.mark.asyncio 47 + async def test_current_track_returns_pydantic_model(httpx_mock: HTTPXMock) -> None: 48 + httpx_mock.add_response( 49 + json=_ok( 50 + { 51 + "currentTrack": { 52 + "title": "Money", 53 + "artist": "Pink Floyd", 54 + "album": "The Dark Side of the Moon", 55 + "albumArt": "http://x/art.jpg", 56 + "albumId": "a-1", 57 + "length": 382000, 58 + "elapsed": 90000, 59 + } 60 + } 61 + ) 62 + ) 63 + async with RockboxClient() as client: 64 + track = await client.playback.current_track() 65 + assert track is not None 66 + assert track.title == "Money" 67 + # Snake_case Python access for camelCase wire fields 68 + assert track.album_art == "http://x/art.jpg" 69 + assert track.album_id == "a-1" 70 + assert track.length == 382000 71 + 72 + 73 + @pytest.mark.asyncio 74 + async def test_search_results_parse(httpx_mock: HTTPXMock) -> None: 75 + httpx_mock.add_response( 76 + json=_ok( 77 + { 78 + "search": { 79 + "artists": [], 80 + "albums": [ 81 + { 82 + "id": "1", 83 + "title": "X", 84 + "artist": "Y", 85 + "year": 1973, 86 + "yearString": "1973", 87 + "albumArt": None, 88 + "md5": "deadbeef", 89 + "artistId": "a", 90 + "copyrightMessage": None, 91 + } 92 + ], 93 + "tracks": [], 94 + "likedTracks": [], 95 + "likedAlbums": [], 96 + } 97 + } 98 + ) 99 + ) 100 + async with RockboxClient() as client: 101 + results = await client.library.search("x") 102 + assert len(results.albums) == 1 103 + assert results.albums[0].year == 1973 104 + 105 + 106 + @pytest.mark.asyncio 107 + async def test_graphql_error_raises(httpx_mock: HTTPXMock) -> None: 108 + httpx_mock.add_response( 109 + json={"data": None, "errors": [{"message": "boom"}]} 110 + ) 111 + async with RockboxClient() as client: 112 + with pytest.raises(RockboxGraphQLError) as excinfo: 113 + await client.playback.status() 114 + assert "boom" in str(excinfo.value) 115 + 116 + 117 + @pytest.mark.asyncio 118 + async def test_network_error_raises(httpx_mock: HTTPXMock) -> None: 119 + httpx_mock.add_response(status_code=502) 120 + async with RockboxClient() as client: 121 + with pytest.raises(RockboxNetworkError): 122 + await client.playback.status() 123 + 124 + 125 + @pytest.mark.asyncio 126 + async def test_insert_position_serialised_as_int(httpx_mock: HTTPXMock) -> None: 127 + httpx_mock.add_response(json=_ok({"insertTracks": True})) 128 + async with RockboxClient() as client: 129 + await client.playlist.insert_tracks(["/a.mp3"], InsertPosition.LAST) 130 + 131 + request = httpx_mock.get_request() 132 + assert request is not None 133 + body = request.read().decode() 134 + assert '"position":2' in body or '"position": 2' in body
+60
sdk/python/tests/test_events.py
··· 1 + """Tests for the async EventEmitter.""" 2 + 3 + from __future__ import annotations 4 + 5 + import asyncio 6 + 7 + import pytest 8 + 9 + from rockbox_sdk import EventEmitter 10 + 11 + 12 + @pytest.mark.asyncio 13 + async def test_sync_listener() -> None: 14 + emitter = EventEmitter() 15 + received: list[int] = [] 16 + emitter.on("ping", lambda x: received.append(x)) 17 + await emitter.emit("ping", 1) 18 + await emitter.emit("ping", 2) 19 + assert received == [1, 2] 20 + 21 + 22 + @pytest.mark.asyncio 23 + async def test_async_listener() -> None: 24 + emitter = EventEmitter() 25 + received: list[str] = [] 26 + 27 + @emitter.on("greet") 28 + async def listener(msg: str) -> None: 29 + await asyncio.sleep(0) 30 + received.append(msg) 31 + 32 + await emitter.emit("greet", "hi") 33 + assert received == ["hi"] 34 + 35 + 36 + @pytest.mark.asyncio 37 + async def test_off_removes_listener() -> None: 38 + emitter = EventEmitter() 39 + received: list[int] = [] 40 + 41 + def listener(x: int) -> None: 42 + received.append(x) 43 + 44 + emitter.on("ping", listener) 45 + await emitter.emit("ping", 1) 46 + emitter.off("ping", listener) 47 + await emitter.emit("ping", 2) 48 + assert received == [1] 49 + 50 + 51 + @pytest.mark.asyncio 52 + async def test_remove_all_listeners() -> None: 53 + emitter = EventEmitter() 54 + received: list[int] = [] 55 + emitter.on("a", lambda x: received.append(x)) 56 + emitter.on("b", lambda x: received.append(x * 10)) 57 + emitter.remove_all_listeners() 58 + await emitter.emit("a", 1) 59 + await emitter.emit("b", 2) 60 + assert received == []
+54
sdk/python/tests/test_plugin.py
··· 1 + """Tests for the plugin registry.""" 2 + 3 + from __future__ import annotations 4 + 5 + import pytest 6 + 7 + from rockbox_sdk import EventEmitter, PluginContext, PluginRegistry 8 + 9 + 10 + class _Plugin: 11 + name = "demo" 12 + version = "1.0.0" 13 + description = "test" 14 + 15 + def __init__(self) -> None: 16 + self.installed = False 17 + self.uninstalled = False 18 + 19 + def install(self, ctx: PluginContext) -> None: 20 + self.installed = True 21 + 22 + def uninstall(self) -> None: 23 + self.uninstalled = True 24 + 25 + 26 + @pytest.mark.asyncio 27 + async def test_register_and_unregister() -> None: 28 + reg = PluginRegistry() 29 + plugin = _Plugin() 30 + 31 + async def fake_query(gql: str, variables=None): # noqa: ANN001, ARG001 32 + return {} 33 + 34 + ctx = PluginContext(query=fake_query, events=EventEmitter()) 35 + await reg.register(plugin, ctx) 36 + assert reg.has("demo") 37 + assert plugin.installed 38 + 39 + await reg.unregister("demo") 40 + assert not reg.has("demo") 41 + assert plugin.uninstalled 42 + 43 + 44 + @pytest.mark.asyncio 45 + async def test_duplicate_register_raises() -> None: 46 + reg = PluginRegistry() 47 + 48 + async def fake_query(gql: str, variables=None): # noqa: ANN001, ARG001 49 + return {} 50 + 51 + ctx = PluginContext(query=fake_query, events=EventEmitter()) 52 + await reg.register(_Plugin(), ctx) 53 + with pytest.raises(ValueError): 54 + await reg.register(_Plugin(), ctx)
+691
sdk/python/uv.lock
··· 1 + version = 1 2 + revision = 3 3 + requires-python = ">=3.10" 4 + resolution-markers = [ 5 + "python_full_version >= '3.15'", 6 + "python_full_version < '3.15'", 7 + ] 8 + 9 + [[package]] 10 + name = "annotated-types" 11 + version = "0.7.0" 12 + source = { registry = "https://pypi.org/simple" } 13 + sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } 14 + wheels = [ 15 + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, 16 + ] 17 + 18 + [[package]] 19 + name = "anyio" 20 + version = "4.13.0" 21 + source = { registry = "https://pypi.org/simple" } 22 + dependencies = [ 23 + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 24 + { name = "idna" }, 25 + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 26 + ] 27 + sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } 28 + wheels = [ 29 + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, 30 + ] 31 + 32 + [[package]] 33 + name = "backports-asyncio-runner" 34 + version = "1.2.0" 35 + source = { registry = "https://pypi.org/simple" } 36 + sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } 37 + wheels = [ 38 + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, 39 + ] 40 + 41 + [[package]] 42 + name = "certifi" 43 + version = "2026.4.22" 44 + source = { registry = "https://pypi.org/simple" } 45 + sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } 46 + wheels = [ 47 + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, 48 + ] 49 + 50 + [[package]] 51 + name = "colorama" 52 + version = "0.4.6" 53 + source = { registry = "https://pypi.org/simple" } 54 + sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 55 + wheels = [ 56 + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 57 + ] 58 + 59 + [[package]] 60 + name = "exceptiongroup" 61 + version = "1.3.1" 62 + source = { registry = "https://pypi.org/simple" } 63 + dependencies = [ 64 + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 65 + ] 66 + sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } 67 + wheels = [ 68 + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, 69 + ] 70 + 71 + [[package]] 72 + name = "h11" 73 + version = "0.16.0" 74 + source = { registry = "https://pypi.org/simple" } 75 + sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } 76 + wheels = [ 77 + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, 78 + ] 79 + 80 + [[package]] 81 + name = "httpcore" 82 + version = "1.0.9" 83 + source = { registry = "https://pypi.org/simple" } 84 + dependencies = [ 85 + { name = "certifi" }, 86 + { name = "h11" }, 87 + ] 88 + sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } 89 + wheels = [ 90 + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, 91 + ] 92 + 93 + [[package]] 94 + name = "httpx" 95 + version = "0.28.1" 96 + source = { registry = "https://pypi.org/simple" } 97 + dependencies = [ 98 + { name = "anyio" }, 99 + { name = "certifi" }, 100 + { name = "httpcore" }, 101 + { name = "idna" }, 102 + ] 103 + sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } 104 + wheels = [ 105 + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, 106 + ] 107 + 108 + [[package]] 109 + name = "idna" 110 + version = "3.13" 111 + source = { registry = "https://pypi.org/simple" } 112 + sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } 113 + wheels = [ 114 + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, 115 + ] 116 + 117 + [[package]] 118 + name = "iniconfig" 119 + version = "2.3.0" 120 + source = { registry = "https://pypi.org/simple" } 121 + sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } 122 + wheels = [ 123 + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, 124 + ] 125 + 126 + [[package]] 127 + name = "librt" 128 + version = "0.9.0" 129 + source = { registry = "https://pypi.org/simple" } 130 + sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } 131 + wheels = [ 132 + { url = "https://files.pythonhosted.org/packages/f3/4a/c64265d71b84030174ff3ac2cd16d8b664072afab8c41fccd8e2ee5a6f8d/librt-0.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f8e12706dcb8ff6b3ed57514a19e45c49ad00bcd423e87b2b2e4b5f64578443", size = 67529, upload-time = "2026-04-09T16:04:27.373Z" }, 133 + { url = "https://files.pythonhosted.org/packages/23/b1/30ca0b3a8bdac209a00145c66cf42e5e7da2cc056ffc6ebc5c7b430ddd34/librt-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e3dda8345307fd7306db0ed0cb109a63a2c85ba780eb9dc2d09b2049a931f9c", size = 70248, upload-time = "2026-04-09T16:04:28.758Z" }, 134 + { url = "https://files.pythonhosted.org/packages/fa/fc/c6018dc181478d6ac5aa24a5846b8185101eb90894346db239eb3ea53209/librt-0.9.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:de7dac64e3eb832ffc7b840eb8f52f76420cde1b845be51b2a0f6b870890645e", size = 202184, upload-time = "2026-04-09T16:04:29.893Z" }, 135 + { url = "https://files.pythonhosted.org/packages/bf/58/d69629f002203370ef41ea69ff71c49a2c618aec39b226ff49986ecd8623/librt-0.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22a904cbdb678f7cb348c90d543d3c52f581663d687992fee47fd566dcbf5285", size = 212926, upload-time = "2026-04-09T16:04:31.126Z" }, 136 + { url = "https://files.pythonhosted.org/packages/cc/55/01d859f57824e42bd02465c77bec31fa5ef9d8c2bcee702ccf8ef1b9f508/librt-0.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224b9727eb8bc188bc3bcf29d969dba0cd61b01d9bac80c41575520cc4baabb2", size = 225664, upload-time = "2026-04-09T16:04:32.352Z" }, 137 + { url = "https://files.pythonhosted.org/packages/9b/02/32f63ad0ef085a94a70315291efe1151a48b9947af12261882f8445b2a30/librt-0.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e94cbc6ad9a6aeea46d775cbb11f361022f778a9cc8cc90af653d3a594b057ce", size = 219534, upload-time = "2026-04-09T16:04:33.667Z" }, 138 + { url = "https://files.pythonhosted.org/packages/6a/5a/9d77111a183c885acf3b3b6e4c00f5b5b07b5817028226499a55f1fedc59/librt-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7bc30ad339f4e1a01d4917d645e522a0bc0030644d8973f6346397c93ba1503f", size = 227322, upload-time = "2026-04-09T16:04:34.945Z" }, 139 + { url = "https://files.pythonhosted.org/packages/d5/e7/05d700c93063753e12ab230b972002a3f8f3b9c95d8a980c2f646c8b6963/librt-0.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:56d65b583cf43b8cf4c8fbe1e1da20fa3076cc32a1149a141507af1062718236", size = 223407, upload-time = "2026-04-09T16:04:36.22Z" }, 140 + { url = "https://files.pythonhosted.org/packages/c0/26/26c3124823c67c987456977c683da9a27cc874befc194ddcead5f9988425/librt-0.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0a1be03168b2691ba61927e299b352a6315189199ca18a57b733f86cb3cc8d38", size = 221302, upload-time = "2026-04-09T16:04:37.62Z" }, 141 + { url = "https://files.pythonhosted.org/packages/50/2b/c7cc2be5cf4ff7b017d948a789256288cb33a517687ff1995e72a7eea79f/librt-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63c12efcd160e1d14da11af0c46c0217473e1e0d2ae1acbccc83f561ea4c2a7b", size = 243893, upload-time = "2026-04-09T16:04:38.909Z" }, 142 + { url = "https://files.pythonhosted.org/packages/62/d3/da553d37417a337d12660450535d5fd51373caffbedf6962173c87867246/librt-0.9.0-cp310-cp310-win32.whl", hash = "sha256:e9002e98dcb1c0a66723592520decd86238ddcef168b37ff6cfb559200b4b774", size = 55375, upload-time = "2026-04-09T16:04:40.148Z" }, 143 + { url = "https://files.pythonhosted.org/packages/9b/5a/46fa357bab8311b6442a83471591f2f9e5b15ecc1d2121a43725e0c529b8/librt-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9fcb461fbf70654a52a7cc670e606f04449e2374c199b1825f754e16dacfedd8", size = 62581, upload-time = "2026-04-09T16:04:41.452Z" }, 144 + { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155, upload-time = "2026-04-09T16:04:42.933Z" }, 145 + { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916, upload-time = "2026-04-09T16:04:44.042Z" }, 146 + { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635, upload-time = "2026-04-09T16:04:45.5Z" }, 147 + { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051, upload-time = "2026-04-09T16:04:47.016Z" }, 148 + { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031, upload-time = "2026-04-09T16:04:48.207Z" }, 149 + { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069, upload-time = "2026-04-09T16:04:50.025Z" }, 150 + { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857, upload-time = "2026-04-09T16:04:51.684Z" }, 151 + { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865, upload-time = "2026-04-09T16:04:52.949Z" }, 152 + { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451, upload-time = "2026-04-09T16:04:54.174Z" }, 153 + { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300, upload-time = "2026-04-09T16:04:55.452Z" }, 154 + { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668, upload-time = "2026-04-09T16:04:56.689Z" }, 155 + { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976, upload-time = "2026-04-09T16:04:57.733Z" }, 156 + { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502, upload-time = "2026-04-09T16:04:58.806Z" }, 157 + { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, 158 + { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, 159 + { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, 160 + { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, 161 + { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, 162 + { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, 163 + { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, 164 + { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, 165 + { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, 166 + { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, 167 + { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, 168 + { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, 169 + { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, 170 + { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, 171 + { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, 172 + { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, 173 + { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, 174 + { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, 175 + { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, 176 + { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, 177 + { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, 178 + { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, 179 + { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, 180 + { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, 181 + { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, 182 + { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, 183 + { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, 184 + { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, 185 + { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, 186 + { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, 187 + { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, 188 + { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, 189 + { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, 190 + { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, 191 + { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, 192 + { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, 193 + { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, 194 + { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, 195 + { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, 196 + { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, 197 + { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, 198 + { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, 199 + { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, 200 + { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, 201 + { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, 202 + { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, 203 + { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, 204 + { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, 205 + { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, 206 + { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, 207 + { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, 208 + { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, 209 + ] 210 + 211 + [[package]] 212 + name = "mypy" 213 + version = "1.20.2" 214 + source = { registry = "https://pypi.org/simple" } 215 + dependencies = [ 216 + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, 217 + { name = "mypy-extensions" }, 218 + { name = "pathspec" }, 219 + { name = "tomli", marker = "python_full_version < '3.11'" }, 220 + { name = "typing-extensions" }, 221 + ] 222 + sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } 223 + wheels = [ 224 + { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, 225 + { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, 226 + { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, 227 + { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, 228 + { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, 229 + { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, 230 + { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, 231 + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, 232 + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, 233 + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, 234 + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, 235 + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, 236 + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, 237 + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, 238 + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, 239 + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, 240 + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, 241 + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, 242 + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, 243 + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, 244 + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, 245 + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, 246 + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, 247 + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, 248 + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, 249 + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, 250 + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, 251 + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, 252 + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, 253 + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, 254 + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, 255 + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, 256 + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, 257 + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, 258 + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, 259 + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, 260 + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, 261 + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, 262 + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, 263 + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, 264 + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, 265 + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, 266 + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, 267 + ] 268 + 269 + [[package]] 270 + name = "mypy-extensions" 271 + version = "1.1.0" 272 + source = { registry = "https://pypi.org/simple" } 273 + sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } 274 + wheels = [ 275 + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, 276 + ] 277 + 278 + [[package]] 279 + name = "packaging" 280 + version = "26.2" 281 + source = { registry = "https://pypi.org/simple" } 282 + sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } 283 + wheels = [ 284 + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, 285 + ] 286 + 287 + [[package]] 288 + name = "pathspec" 289 + version = "1.1.1" 290 + source = { registry = "https://pypi.org/simple" } 291 + sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } 292 + wheels = [ 293 + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, 294 + ] 295 + 296 + [[package]] 297 + name = "pluggy" 298 + version = "1.6.0" 299 + source = { registry = "https://pypi.org/simple" } 300 + sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } 301 + wheels = [ 302 + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, 303 + ] 304 + 305 + [[package]] 306 + name = "pydantic" 307 + version = "2.13.3" 308 + source = { registry = "https://pypi.org/simple" } 309 + dependencies = [ 310 + { name = "annotated-types" }, 311 + { name = "pydantic-core" }, 312 + { name = "typing-extensions" }, 313 + { name = "typing-inspection" }, 314 + ] 315 + sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } 316 + wheels = [ 317 + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, 318 + ] 319 + 320 + [[package]] 321 + name = "pydantic-core" 322 + version = "2.46.3" 323 + source = { registry = "https://pypi.org/simple" } 324 + dependencies = [ 325 + { name = "typing-extensions" }, 326 + ] 327 + sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } 328 + wheels = [ 329 + { url = "https://files.pythonhosted.org/packages/22/98/b50eb9a411e87483b5c65dba4fa430a06bac4234d3403a40e5a9905ebcd0/pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1", size = 2108971, upload-time = "2026-04-20T14:43:51.945Z" }, 330 + { url = "https://files.pythonhosted.org/packages/08/4b/f364b9d161718ff2217160a4b5d41ce38de60aed91c3689ebffa1c939d23/pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f", size = 1949588, upload-time = "2026-04-20T14:44:10.386Z" }, 331 + { url = "https://files.pythonhosted.org/packages/8f/8b/30bd03ee83b2f5e29f5ba8e647ab3c456bf56f2ec72fdbcc0215484a0854/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3", size = 1975986, upload-time = "2026-04-20T14:43:57.106Z" }, 332 + { url = "https://files.pythonhosted.org/packages/3c/54/13ccf954d84ec275d5d023d5786e4aa48840bc9f161f2838dc98e1153518/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a", size = 2055830, upload-time = "2026-04-20T14:44:15.499Z" }, 333 + { url = "https://files.pythonhosted.org/packages/be/0e/65f38125e660fdbd72aa858e7dfae893645cfa0e7b13d333e174a367cd23/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807", size = 2222340, upload-time = "2026-04-20T14:41:51.353Z" }, 334 + { url = "https://files.pythonhosted.org/packages/d1/88/f3ab7739efe0e7e80777dbb84c59eb98518e3f57ea433206194c2e425272/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda", size = 2280727, upload-time = "2026-04-20T14:41:30.461Z" }, 335 + { url = "https://files.pythonhosted.org/packages/2a/6d/c228219080817bec4982f9531cadb18da6aaa770fdeb114f49c237ac2c9f/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57", size = 2092158, upload-time = "2026-04-20T14:44:07.305Z" }, 336 + { url = "https://files.pythonhosted.org/packages/0f/b1/525a16711e7c6d61635fac3b0bd54600b5c5d9f60c6fc5aaab26b64a2297/pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045", size = 2116626, upload-time = "2026-04-20T14:42:34.118Z" }, 337 + { url = "https://files.pythonhosted.org/packages/ef/7c/17d30673351439a6951bf54f564cf2443ab00ae264ec9df00e2efd710eb5/pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943", size = 2160691, upload-time = "2026-04-20T14:41:14.023Z" }, 338 + { url = "https://files.pythonhosted.org/packages/86/66/af8adbcbc0886ead7f1a116606a534d75a307e71e6e08226000d51b880d2/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f", size = 2182543, upload-time = "2026-04-20T14:40:48.886Z" }, 339 + { url = "https://files.pythonhosted.org/packages/b0/37/6de71e0f54c54a4190010f57deb749e1ddf75c568ada3b1320b70067f121/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4", size = 2324513, upload-time = "2026-04-20T14:42:36.121Z" }, 340 + { url = "https://files.pythonhosted.org/packages/51/b1/9fc74ce94f603d5ef59ff258ca9c2c8fb902fb548d340a96f77f4d1c3b7f/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a", size = 2361853, upload-time = "2026-04-20T14:43:24.886Z" }, 341 + { url = "https://files.pythonhosted.org/packages/40/d0/4c652fc592db35f100279ee751d5a145aca1b9a7984b9684ba7c1b5b0535/pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7", size = 1980465, upload-time = "2026-04-20T14:44:46.239Z" }, 342 + { url = "https://files.pythonhosted.org/packages/27/b8/a920453c38afbe1f355e1ea0b0d94a0a3e0b0879d32d793108755fa171d5/pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6", size = 2073884, upload-time = "2026-04-20T14:43:01.201Z" }, 343 + { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, 344 + { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, 345 + { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, 346 + { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, 347 + { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, 348 + { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, 349 + { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, 350 + { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, 351 + { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, 352 + { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, 353 + { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, 354 + { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, 355 + { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, 356 + { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, 357 + { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, 358 + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, 359 + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, 360 + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, 361 + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, 362 + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, 363 + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, 364 + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, 365 + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, 366 + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, 367 + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, 368 + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, 369 + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, 370 + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, 371 + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, 372 + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, 373 + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, 374 + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, 375 + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, 376 + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, 377 + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, 378 + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, 379 + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, 380 + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, 381 + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, 382 + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, 383 + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, 384 + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, 385 + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, 386 + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, 387 + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, 388 + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, 389 + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, 390 + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, 391 + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, 392 + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, 393 + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, 394 + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, 395 + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, 396 + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, 397 + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, 398 + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, 399 + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, 400 + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, 401 + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, 402 + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, 403 + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, 404 + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, 405 + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, 406 + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, 407 + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, 408 + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, 409 + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, 410 + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, 411 + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, 412 + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, 413 + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, 414 + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, 415 + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, 416 + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, 417 + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, 418 + { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, 419 + { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, 420 + { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, 421 + { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, 422 + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, 423 + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, 424 + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, 425 + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, 426 + { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, 427 + { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, 428 + { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, 429 + { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, 430 + { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, 431 + { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, 432 + { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, 433 + { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, 434 + ] 435 + 436 + [[package]] 437 + name = "pygments" 438 + version = "2.20.0" 439 + source = { registry = "https://pypi.org/simple" } 440 + sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } 441 + wheels = [ 442 + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, 443 + ] 444 + 445 + [[package]] 446 + name = "pytest" 447 + version = "9.0.3" 448 + source = { registry = "https://pypi.org/simple" } 449 + dependencies = [ 450 + { name = "colorama", marker = "sys_platform == 'win32'" }, 451 + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 452 + { name = "iniconfig" }, 453 + { name = "packaging" }, 454 + { name = "pluggy" }, 455 + { name = "pygments" }, 456 + { name = "tomli", marker = "python_full_version < '3.11'" }, 457 + ] 458 + sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } 459 + wheels = [ 460 + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, 461 + ] 462 + 463 + [[package]] 464 + name = "pytest-asyncio" 465 + version = "1.3.0" 466 + source = { registry = "https://pypi.org/simple" } 467 + dependencies = [ 468 + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, 469 + { name = "pytest" }, 470 + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 471 + ] 472 + sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } 473 + wheels = [ 474 + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, 475 + ] 476 + 477 + [[package]] 478 + name = "pytest-httpx" 479 + version = "0.36.2" 480 + source = { registry = "https://pypi.org/simple" } 481 + dependencies = [ 482 + { name = "httpx" }, 483 + { name = "pytest" }, 484 + ] 485 + sdist = { url = "https://files.pythonhosted.org/packages/4e/42/f53c58570e80d503ade9dd42ce57f2915d14bcbe25f6308138143950d1d6/pytest_httpx-0.36.2.tar.gz", hash = "sha256:05a56527484f7f4e8c856419ea379b8dc359c36801c4992fdb330f294c690356", size = 57683, upload-time = "2026-04-09T13:57:19.837Z" } 486 + wheels = [ 487 + { url = "https://files.pythonhosted.org/packages/1e/55/1fa65f8e4fceb19dd6daa867c162ad845d547f6058cd92b4b02384a44777/pytest_httpx-0.36.2-py3-none-any.whl", hash = "sha256:d42ebd5679442dc7bfb0c48e0767b6562e9bc4534d805127b0084171886a5e22", size = 20315, upload-time = "2026-04-09T13:57:18.587Z" }, 488 + ] 489 + 490 + [[package]] 491 + name = "rockbox-sdk" 492 + version = "0.1.0" 493 + source = { editable = "." } 494 + dependencies = [ 495 + { name = "httpx" }, 496 + { name = "pydantic" }, 497 + { name = "websockets" }, 498 + ] 499 + 500 + [package.dev-dependencies] 501 + dev = [ 502 + { name = "mypy" }, 503 + { name = "pytest" }, 504 + { name = "pytest-asyncio" }, 505 + { name = "pytest-httpx" }, 506 + { name = "ruff" }, 507 + ] 508 + 509 + [package.metadata] 510 + requires-dist = [ 511 + { name = "httpx", specifier = ">=0.27" }, 512 + { name = "pydantic", specifier = ">=2.6" }, 513 + { name = "websockets", specifier = ">=13" }, 514 + ] 515 + 516 + [package.metadata.requires-dev] 517 + dev = [ 518 + { name = "mypy", specifier = ">=1.10" }, 519 + { name = "pytest", specifier = ">=8.0" }, 520 + { name = "pytest-asyncio", specifier = ">=0.24" }, 521 + { name = "pytest-httpx", specifier = ">=0.32" }, 522 + { name = "ruff", specifier = ">=0.6" }, 523 + ] 524 + 525 + [[package]] 526 + name = "ruff" 527 + version = "0.15.12" 528 + source = { registry = "https://pypi.org/simple" } 529 + sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } 530 + wheels = [ 531 + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, 532 + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, 533 + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, 534 + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, 535 + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, 536 + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, 537 + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, 538 + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, 539 + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, 540 + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, 541 + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, 542 + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, 543 + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, 544 + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, 545 + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, 546 + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, 547 + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, 548 + ] 549 + 550 + [[package]] 551 + name = "tomli" 552 + version = "2.4.1" 553 + source = { registry = "https://pypi.org/simple" } 554 + sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } 555 + wheels = [ 556 + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, 557 + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, 558 + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, 559 + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, 560 + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, 561 + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, 562 + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, 563 + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, 564 + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, 565 + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, 566 + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, 567 + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, 568 + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, 569 + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, 570 + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, 571 + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, 572 + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, 573 + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, 574 + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, 575 + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, 576 + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, 577 + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, 578 + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, 579 + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, 580 + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, 581 + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, 582 + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, 583 + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, 584 + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, 585 + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, 586 + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, 587 + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, 588 + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, 589 + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, 590 + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, 591 + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, 592 + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, 593 + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, 594 + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, 595 + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, 596 + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, 597 + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, 598 + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, 599 + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, 600 + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, 601 + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, 602 + ] 603 + 604 + [[package]] 605 + name = "typing-extensions" 606 + version = "4.15.0" 607 + source = { registry = "https://pypi.org/simple" } 608 + sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 609 + wheels = [ 610 + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 611 + ] 612 + 613 + [[package]] 614 + name = "typing-inspection" 615 + version = "0.4.2" 616 + source = { registry = "https://pypi.org/simple" } 617 + dependencies = [ 618 + { name = "typing-extensions" }, 619 + ] 620 + sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } 621 + wheels = [ 622 + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, 623 + ] 624 + 625 + [[package]] 626 + name = "websockets" 627 + version = "16.0" 628 + source = { registry = "https://pypi.org/simple" } 629 + sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } 630 + wheels = [ 631 + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, 632 + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, 633 + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, 634 + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, 635 + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, 636 + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, 637 + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, 638 + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, 639 + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, 640 + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, 641 + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, 642 + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, 643 + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, 644 + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, 645 + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, 646 + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, 647 + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, 648 + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, 649 + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, 650 + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, 651 + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, 652 + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, 653 + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, 654 + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, 655 + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, 656 + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, 657 + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, 658 + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, 659 + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, 660 + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, 661 + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, 662 + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, 663 + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, 664 + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, 665 + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, 666 + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, 667 + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, 668 + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, 669 + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, 670 + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, 671 + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, 672 + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, 673 + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, 674 + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, 675 + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, 676 + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, 677 + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, 678 + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, 679 + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, 680 + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, 681 + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, 682 + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, 683 + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, 684 + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, 685 + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, 686 + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, 687 + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, 688 + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, 689 + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, 690 + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, 691 + ]
+10
sdk/ruby/.gitignore
··· 1 + *.gem 2 + .bundle 3 + Gemfile.lock 4 + /pkg 5 + /tmp 6 + /coverage 7 + /vendor/bundle 8 + .ruby-version 9 + .idea 10 + .vscode
+3
sdk/ruby/Gemfile
··· 1 + source "https://rubygems.org" 2 + 3 + gemspec
+21
sdk/ruby/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app> 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+464
sdk/ruby/README.md
··· 1 + # rockbox 2 + 3 + Ruby SDK for [Rockbox](https://www.rockbox.org) — a builder-friendly, block-friendly GraphQL client with real-time event subscriptions and a plugin system. 4 + 5 + ```ruby 6 + require "rockbox" 7 + 8 + client = Rockbox::Client.build do |c| 9 + c.host = "localhost" 10 + c.port = 6062 11 + end 12 + 13 + client.on(:track_changed) { |t| puts "▶ #{t.title} — #{t.artist}" } 14 + client.connect 15 + 16 + results = client.library.search("dark side") 17 + client.playback.play_album(results.albums.first.id, shuffle: true) 18 + ``` 19 + 20 + --- 21 + 22 + ## Table of contents 23 + 24 + - [Installation](#installation) 25 + - [Quick start](#quick-start) 26 + - [Configuration](#configuration) 27 + - [API reference](#api-reference) 28 + - [Playback](#playback) 29 + - [Library](#library) 30 + - [Playlist (queue)](#playlist-queue) 31 + - [Saved playlists](#saved-playlists) 32 + - [Smart playlists](#smart-playlists) 33 + - [Sound](#sound) 34 + - [Settings](#settings) 35 + - [System](#system) 36 + - [Browse (filesystem)](#browse-filesystem) 37 + - [Devices](#devices) 38 + - [Bluetooth](#bluetooth) 39 + - [Real-time events](#real-time-events) 40 + - [Plugin system](#plugin-system) 41 + - [Error handling](#error-handling) 42 + - [Raw GraphQL queries](#raw-graphql-queries) 43 + 44 + --- 45 + 46 + ## Installation 47 + 48 + ```sh 49 + gem install rockbox 50 + ``` 51 + 52 + Or with Bundler: 53 + 54 + ```ruby 55 + # Gemfile 56 + gem "rockbox" 57 + ``` 58 + 59 + `rockboxd` must be running and reachable. The SDK targets **Ruby 3.0+** and connects to `http://localhost:6062/graphql` by default. 60 + 61 + --- 62 + 63 + ## Quick start 64 + 65 + ```ruby 66 + require "rockbox" 67 + 68 + client = Rockbox::Client.new 69 + 70 + # Optional — start WebSocket subscriptions for real-time events. 71 + client.connect 72 + 73 + # What's playing? 74 + if (track = client.playback.current_track) 75 + puts "Now playing: #{track.title} — #{track.artist}" 76 + end 77 + 78 + # Search the library. 79 + results = client.library.search("dark side") 80 + puts "Found #{results.albums.size} albums and #{results.tracks.size} tracks" 81 + 82 + # Play an album with shuffle. 83 + client.playback.play_album(results.albums.first.id, shuffle: true) 84 + 85 + # React to track changes. 86 + client.on(:track_changed) do |track| 87 + puts "▶ #{track.title} by #{track.artist}" 88 + end 89 + 90 + # Tear down when done. 91 + client.disconnect 92 + ``` 93 + 94 + --- 95 + 96 + ## Configuration 97 + 98 + Three equivalent ways to configure the client. Pick the one that fits your style. 99 + 100 + ```ruby 101 + # 1. Defaults — localhost:6062. 102 + client = Rockbox::Client.new 103 + 104 + # 2. Keyword arguments. 105 + client = Rockbox::Client.new(host: "192.168.1.42", port: 6062) 106 + 107 + # 3. Builder block (great for application initializers). 108 + client = Rockbox::Client.build do |c| 109 + c.host = "192.168.1.42" 110 + c.port = 6062 111 + end 112 + 113 + # 4. Fully custom URLs (useful behind a reverse proxy). 114 + client = Rockbox::Client.new( 115 + http_url: "https://music.home/graphql", 116 + ws_url: "wss://music.home/graphql" 117 + ) 118 + 119 + # Top-level shorthand. 120 + client = Rockbox.new(host: "localhost") 121 + ``` 122 + 123 + | Option | Type | Default | Description | 124 + |----------------|----------|---------------------------------|--------------------------------------| 125 + | `host` | String | `"localhost"` | Hostname or IP of rockboxd | 126 + | `port` | Integer | `6062` | GraphQL port | 127 + | `http_url` | String | `http://{host}:{port}/graphql` | Override the full HTTP URL | 128 + | `ws_url` | String | `ws://{host}:{port}/graphql` | Override the full WebSocket URL | 129 + | `open_timeout` | Integer | `5` | HTTP connect timeout (seconds) | 130 + | `read_timeout` | Integer | `30` | HTTP read timeout (seconds) | 131 + 132 + --- 133 + 134 + ## API reference 135 + 136 + The client exposes a domain namespace per concern (Mopidy-style). Every method returns idiomatic Ruby data — `Struct` instances with `snake_case` accessors. 137 + 138 + ### Playback 139 + 140 + ```ruby 141 + client.playback 142 + ``` 143 + 144 + ```ruby 145 + # Status 146 + client.playback.status # => Integer (Rockbox::PlaybackStatus::PLAYING, etc.) 147 + client.playback.status_name # => :playing | :paused | :stopped | :unknown 148 + 149 + # Current/next track 150 + track = client.playback.current_track # => Rockbox::Track 151 + client.playback.next_track 152 + client.playback.file_position 153 + 154 + # Transport controls 155 + client.playback.play(elapsed: 0, offset: 0) 156 + client.playback.pause 157 + client.playback.resume 158 + client.playback.next! 159 + client.playback.previous! 160 + client.playback.seek(60_000) # ms 161 + client.playback.stop 162 + client.playback.flush_and_reload 163 + 164 + # One-shot play helpers 165 + client.playback.play_track("/Music/song.mp3") 166 + client.playback.play_album(album_id, shuffle: true) 167 + client.playback.play_artist(artist_id) 168 + client.playback.play_playlist(playlist_id, shuffle: true, position: 0) 169 + client.playback.play_directory("/Music/Pink Floyd", recurse: true) 170 + client.playback.play_liked_tracks(shuffle: true) 171 + client.playback.play_all_tracks 172 + ``` 173 + 174 + ### Library 175 + 176 + ```ruby 177 + # Albums 178 + client.library.albums # => Array<Rockbox::Album> 179 + client.library.album(id) # => Rockbox::Album | nil 180 + client.library.liked_albums 181 + client.library.like_album(id) 182 + client.library.unlike_album(id) 183 + 184 + # Artists 185 + client.library.artists 186 + client.library.artist(id) 187 + 188 + # Tracks 189 + client.library.tracks 190 + client.library.track(id) 191 + client.library.liked_tracks 192 + client.library.like_track(id) 193 + client.library.unlike_track(id) 194 + 195 + # Search 196 + results = client.library.search("daft punk") 197 + results.artists # => Array<Rockbox::Artist> 198 + results.albums # => Array<Rockbox::Album> 199 + results.tracks # => Array<Rockbox::Track> 200 + results.liked_tracks 201 + results.liked_albums 202 + 203 + # Library scan 204 + client.library.scan 205 + ``` 206 + 207 + ### Playlist (queue) 208 + 209 + ```ruby 210 + playlist = client.playlist.current 211 + playlist.amount # 42 212 + playlist.index # currently playing index 213 + playlist.tracks # Array<Rockbox::Track> 214 + 215 + client.playlist.amount # convenience for playlist.current.amount 216 + 217 + # Inserts (paths or track IDs) 218 + client.playlist.insert_tracks(["/Music/a.mp3", "/Music/b.mp3"], 219 + position: Rockbox::InsertPosition::NEXT) 220 + client.playlist.insert_directory("/Music/Pink Floyd", position: Rockbox::InsertPosition::LAST) 221 + client.playlist.insert_album(album_id) 222 + 223 + # Mutations 224 + client.playlist.remove_track(3) 225 + client.playlist.clear 226 + client.playlist.shuffle 227 + 228 + # Create + start a temporary playlist 229 + client.playlist.create("Tonight", ["/Music/a.mp3", "/Music/b.mp3"]) 230 + client.playlist.start(start_index: 0) 231 + client.playlist.resume 232 + ``` 233 + 234 + `Rockbox::InsertPosition` constants: `NEXT`, `AFTER_CURRENT`, `LAST`, `FIRST`. 235 + 236 + ### Saved playlists 237 + 238 + ```ruby 239 + client.saved_playlists.list # => Array<Rockbox::SavedPlaylist> 240 + client.saved_playlists.list(folder_id: "f_1") 241 + client.saved_playlists.get("pl_42") 242 + client.saved_playlists.track_ids("pl_42") 243 + 244 + # Builder block (any field is optional) 245 + playlist = client.saved_playlists.create(name: "Late nights") do |p| 246 + p.description = "After-dark vibes" 247 + p.image = "https://…/cover.png" 248 + p.track_ids = ["abc", "def"] 249 + end 250 + 251 + # Or pass kwargs directly 252 + client.saved_playlists.update("pl_42", name: "Renamed", description: "…") 253 + client.saved_playlists.add_tracks("pl_42", ["abc", "def"]) 254 + client.saved_playlists.remove_track("pl_42", "abc") 255 + client.saved_playlists.delete("pl_42") 256 + client.saved_playlists.play("pl_42") 257 + 258 + # Folders 259 + client.saved_playlists.folders 260 + client.saved_playlists.create_folder("Workout") 261 + client.saved_playlists.delete_folder("f_1") 262 + ``` 263 + 264 + ### Smart playlists 265 + 266 + ```ruby 267 + client.smart_playlists.list 268 + client.smart_playlists.get(id) 269 + client.smart_playlists.track_ids(id) 270 + client.smart_playlists.create(name: "Heavy hitters", rules: rules_json) 271 + client.smart_playlists.update(id, name: "…", rules: new_rules) 272 + client.smart_playlists.delete(id) 273 + client.smart_playlists.play(id) 274 + 275 + # Listening stats 276 + client.smart_playlists.track_stats(track_id) 277 + client.smart_playlists.record_played(track_id) 278 + client.smart_playlists.record_skipped(track_id) 279 + ``` 280 + 281 + ### Sound 282 + 283 + ```ruby 284 + info = client.sound.volume # => Rockbox::VolumeInfo(volume:, min:, max:) 285 + client.sound.adjust(3) # +3 steps 286 + client.sound.up # +1 step 287 + client.sound.down # -1 step 288 + ``` 289 + 290 + ### Settings 291 + 292 + ```ruby 293 + settings = client.settings.get # => Rockbox::UserSettings (Struct) 294 + settings.volume # -20 295 + settings.shuffle # false 296 + 297 + # Save with a builder block — only the fields you set are sent. 298 + client.settings.save do |s| 299 + s.volume = -10 300 + s.bass = 4 301 + s.shuffle = true 302 + end 303 + 304 + # Or with a plain Hash. 305 + client.settings.save(volume: -10, shuffle: true) 306 + ``` 307 + 308 + ### System 309 + 310 + ```ruby 311 + client.system.version # "Rockbox v…" 312 + client.system.status # Rockbox::SystemStatus 313 + ``` 314 + 315 + ### Browse (filesystem) 316 + 317 + ```ruby 318 + client.browse.entries("/Music") # => Array<Rockbox::Entry> 319 + client.browse.directories("/Music") # only directories 320 + client.browse.files("/Music/Pink Floyd") # only files 321 + 322 + entry = client.browse.entries.first 323 + Rockbox.directory?(entry) # => true/false 324 + ``` 325 + 326 + ### Devices 327 + 328 + ```ruby 329 + client.devices.list # => Array<Rockbox::Device> 330 + client.devices.get(id) 331 + client.devices.connect(id) 332 + client.devices.disconnect(id) 333 + ``` 334 + 335 + ### Bluetooth 336 + 337 + > Linux only. macOS/Windows builds will return errors. 338 + 339 + ```ruby 340 + client.bluetooth.devices 341 + client.bluetooth.scan(timeout: 5) 342 + client.bluetooth.connect("AA:BB:CC:DD:EE:FF") 343 + client.bluetooth.disconnect("AA:BB:CC:DD:EE:FF") 344 + ``` 345 + 346 + --- 347 + 348 + ## Real-time events 349 + 350 + Call `#connect` to open the WebSocket and start receiving events. The client speaks the GraphQL `graphql-transport-ws` subprotocol. 351 + 352 + ```ruby 353 + client = Rockbox::Client.new 354 + client.connect 355 + 356 + client.on(:track_changed) { |track| puts "▶ #{track.title}" } 357 + client.on(:status_changed) { |status| puts Rockbox::PlaybackStatus.name(status) } 358 + client.on(:playlist_changed) { |playlist| puts "queue: #{playlist.amount} tracks" } 359 + client.on(:ws_open) { puts "connected" } 360 + client.on(:ws_close) { puts "disconnected" } 361 + client.on(:ws_error) { |err| warn err.message } 362 + 363 + # Once-only listener 364 + client.once(:track_changed) { |t| puts "first track: #{t.title}" } 365 + 366 + # Remove a specific listener with the same Proc 367 + listener = ->(t) { puts t.title } 368 + client.on(:track_changed, &listener) 369 + client.off(:track_changed, listener) 370 + 371 + # Remove all listeners for an event (or all events) 372 + client.remove_all_listeners(:track_changed) 373 + client.remove_all_listeners 374 + 375 + # Tear down 376 + client.disconnect 377 + ``` 378 + 379 + | Event | Payload | 380 + |--------------------|--------------------------------------| 381 + | `:track_changed` | `Rockbox::Track` | 382 + | `:status_changed` | `Integer` (`Rockbox::PlaybackStatus`)| 383 + | `:playlist_changed`| `Rockbox::Playlist` | 384 + | `:ws_open` | `nil` | 385 + | `:ws_close` | `nil` | 386 + | `:ws_error` | `Exception` | 387 + 388 + --- 389 + 390 + ## Plugin system 391 + 392 + Plugins are duck-typed objects with `#name`, `#version`, and `#install(context)`. Inherit from `Rockbox::Plugin` for sane defaults. 393 + 394 + ```ruby 395 + class ConsoleScrobbler < Rockbox::Plugin 396 + def name; "console-scrobbler" end 397 + def version; "0.1.0" end 398 + def description; "Logs every track change" end 399 + 400 + def install(ctx) 401 + ctx.events.on(:track_changed) do |track| 402 + puts "♪ #{track.artist} — #{track.title}" 403 + end 404 + end 405 + 406 + def uninstall 407 + # cleanup 408 + end 409 + end 410 + 411 + client.use(ConsoleScrobbler.new) 412 + client.installed_plugins # => [<ConsoleScrobbler ...>] 413 + client.unuse("console-scrobbler") 414 + ``` 415 + 416 + The `PluginContext` exposes: 417 + 418 + - `ctx.query.call(gql, variables = nil)` — issue raw GraphQL operations 419 + - `ctx.events` — the same `Rockbox::EventEmitter` used by the client 420 + 421 + --- 422 + 423 + ## Error handling 424 + 425 + ```ruby 426 + begin 427 + client.library.album("does-not-exist") 428 + rescue Rockbox::GraphQLError => e 429 + e.errors.each { |err| warn err[:message] } 430 + rescue Rockbox::NetworkError => e 431 + warn "rockboxd unreachable: #{e.message}" 432 + end 433 + ``` 434 + 435 + | Class | Raised when… | 436 + |---------------------------|----------------------------------------------| 437 + | `Rockbox::Error` | Base class for every SDK error. | 438 + | `Rockbox::NetworkError` | rockboxd is unreachable / non-2xx response. | 439 + | `Rockbox::GraphQLError` | rockboxd returns a GraphQL `errors` payload. | 440 + 441 + --- 442 + 443 + ## Raw GraphQL queries 444 + 445 + 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. 446 + 447 + ```ruby 448 + data = client.query( 449 + "query LikedSongs { likedTracks { id title } }" 450 + ) 451 + data[:liked_tracks].each { |t| puts t[:title] } 452 + 453 + data = client.query(<<~GQL, { id: "abc" }) 454 + query Track($id: String!) { 455 + track(id: $id) { id title artist } 456 + } 457 + GQL 458 + ``` 459 + 460 + --- 461 + 462 + ## License 463 + 464 + MIT License. See [LICENSE](./LICENSE) for details.
+34
sdk/ruby/bin/console
··· 1 + #!/usr/bin/env ruby 2 + # frozen_string_literal: true 3 + # 4 + # Drop into an IRB session with the SDK pre-loaded. 5 + # 6 + # ./bin/console 7 + # 8 + # Environment variables: 9 + # ROCKBOX_HOST hostname or IP of rockboxd (default: localhost) 10 + # ROCKBOX_PORT GraphQL port (default: 6062) 11 + # ROCKBOX_AUTOCONNECT=1 open the WebSocket immediately 12 + 13 + $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) 14 + require "rockbox" 15 + require "irb" 16 + 17 + host = ENV.fetch("ROCKBOX_HOST", "localhost") 18 + port = ENV.fetch("ROCKBOX_PORT", "6062").to_i 19 + 20 + # `client` is auto-available in IRB. 21 + client = Rockbox::Client.new(host: host, port: port) 22 + 23 + if ENV["ROCKBOX_AUTOCONNECT"] == "1" 24 + client.on(:track_changed) { |t| puts "▶ #{t.title} — #{t.artist}" } 25 + client.connect 26 + end 27 + 28 + puts "Rockbox SDK v#{Rockbox::VERSION} — connected to #{client.configuration.resolved_http_url}" 29 + puts " client.playback.status_name" 30 + puts " client.library.search(\"…\")" 31 + puts " client.connect # start WebSocket subscriptions" 32 + puts 33 + 34 + IRB.start(__FILE__)
+26
sdk/ruby/examples/plugin.rb
··· 1 + # frozen_string_literal: true 2 + # 3 + # Plugin example — listens to track-change events and prints a one-line log. 4 + # 5 + # ruby -Ilib examples/plugin.rb 6 + 7 + require "rockbox" 8 + 9 + class ConsoleScrobbler < Rockbox::Plugin 10 + def name; "console-scrobbler" end 11 + def version; "0.1.0" end 12 + def description; "Logs every track change to stdout" end 13 + 14 + def install(ctx) 15 + ctx.events.on(:track_changed) do |track| 16 + puts "♪ #{Time.now.iso8601} #{track.artist} — #{track.title}" 17 + end 18 + end 19 + end 20 + 21 + client = Rockbox::Client.new 22 + client.use(ConsoleScrobbler.new) 23 + client.connect 24 + 25 + trap("INT") { client.disconnect; exit } 26 + sleep
+32
sdk/ruby/examples/quickstart.rb
··· 1 + # frozen_string_literal: true 2 + # 3 + # Run with: ruby -Ilib examples/quickstart.rb 4 + # 5 + # Make sure rockboxd is reachable on http://localhost:6062. 6 + 7 + require "rockbox" 8 + 9 + # Configure with the builder DSL. 10 + client = Rockbox::Client.build do |c| 11 + c.host = ENV.fetch("ROCKBOX_HOST", "localhost") 12 + c.port = ENV.fetch("ROCKBOX_PORT", "6062").to_i 13 + end 14 + 15 + puts "Rockbox version: #{client.system.version}" 16 + 17 + if (track = client.playback.current_track) 18 + puts "Now playing: #{track.title} — #{track.artist}" 19 + puts " album: #{track.album}, length: #{track.length}ms, elapsed: #{track.elapsed}ms" 20 + else 21 + puts "Nothing playing." 22 + end 23 + 24 + # Search the library. 25 + results = client.library.search("dark side") 26 + puts "Found #{results.albums.size} albums and #{results.tracks.size} tracks" 27 + 28 + # Play the first matching album with shuffle on. 29 + if (first = results.albums.first) 30 + client.playback.play_album(first.id, shuffle: true) 31 + puts "▶ #{first.title} by #{first.artist}" 32 + end
+32
sdk/ruby/examples/subscribe.rb
··· 1 + # frozen_string_literal: true 2 + # 3 + # Subscribe to real-time playback events. Run with: 4 + # ruby -Ilib examples/subscribe.rb 5 + 6 + require "rockbox" 7 + 8 + client = Rockbox::Client.new 9 + 10 + client.on(:track_changed) do |track| 11 + puts "▶ #{track.title} — #{track.artist}" 12 + end 13 + 14 + client.on(:status_changed) do |status| 15 + puts "status -> #{Rockbox::PlaybackStatus.name(status)}" 16 + end 17 + 18 + client.on(:playlist_changed) do |playlist| 19 + puts "playlist now has #{playlist.amount} tracks (index=#{playlist.index})" 20 + end 21 + 22 + client.on(:ws_error) { |err| warn "ws error: #{err.message}" } 23 + 24 + client.connect 25 + puts "Listening for events. Ctrl+C to exit." 26 + 27 + trap("INT") do 28 + client.disconnect 29 + exit 30 + end 31 + 32 + sleep
+19
sdk/ruby/lib/rockbox.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "rockbox/version" 4 + require_relative "rockbox/errors" 5 + require_relative "rockbox/types" 6 + require_relative "rockbox/case_conversion" 7 + require_relative "rockbox/configuration" 8 + require_relative "rockbox/transport" 9 + require_relative "rockbox/events" 10 + require_relative "rockbox/plugin" 11 + require_relative "rockbox/client" 12 + 13 + # Top-level convenience constructor — `Rockbox.new(host: "...")` is a synonym 14 + # for `Rockbox::Client.new(host: "...")`. 15 + module Rockbox 16 + def self.new(**kwargs, &block) 17 + block ? Client.build(&block) : Client.new(**kwargs) 18 + end 19 + end
+52
sdk/ruby/lib/rockbox/api/bluetooth.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class Bluetooth 8 + FIELDS = <<~GQL 9 + fragment BluetoothDeviceFields on BluetoothDevice { 10 + address name paired trusted connected rssi 11 + } 12 + GQL 13 + 14 + def initialize(http) 15 + @http = http 16 + end 17 + 18 + # List paired/known Bluetooth devices (Linux only). 19 + def devices 20 + data = @http.execute("#{FIELDS}\nquery BluetoothDevices { bluetoothDevices { ...BluetoothDeviceFields } }") 21 + Array(data[:bluetooth_devices]).map { |d| BluetoothDevice.from_hash(d) } 22 + end 23 + 24 + # Scan for nearby devices (Linux only). 25 + def scan(timeout: nil) 26 + data = @http.execute(<<~GQL, timeout ? { timeout_secs: timeout } : nil) 27 + #{FIELDS} 28 + mutation BluetoothScan($timeoutSecs: Int) { 29 + bluetoothScan(timeoutSecs: $timeoutSecs) { ...BluetoothDeviceFields } 30 + } 31 + GQL 32 + Array(data[:bluetooth_scan]).map { |d| BluetoothDevice.from_hash(d) } 33 + end 34 + 35 + def connect(address) 36 + @http.execute( 37 + "mutation BluetoothConnect($address: String!) { bluetoothConnect(address: $address) }", 38 + { address: address } 39 + ) 40 + nil 41 + end 42 + 43 + def disconnect(address) 44 + @http.execute( 45 + "mutation BluetoothDisconnect($address: String!) { bluetoothDisconnect(address: $address) }", 46 + { address: address } 47 + ) 48 + nil 49 + end 50 + end 51 + end 52 + end
+31
sdk/ruby/lib/rockbox/api/browse.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class Browse 8 + def initialize(http) 9 + @http = http 10 + end 11 + 12 + # @param path [String, nil] absolute path; nil for the music root. 13 + # @return [Array<Rockbox::Entry>] 14 + def entries(path = nil) 15 + data = @http.execute( 16 + "query Browse($path: String) { treeGetEntries(path: $path) { name attr timeWrite customaction displayName } }", 17 + path ? { path: path } : nil 18 + ) 19 + Array(data[:tree_get_entries]).map { |e| Entry.from_hash(e) } 20 + end 21 + 22 + def directories(path = nil) 23 + entries(path).select { |e| Rockbox.directory?(e) } 24 + end 25 + 26 + def files(path = nil) 27 + entries(path).reject { |e| Rockbox.directory?(e) } 28 + end 29 + end 30 + end 31 + end
+38
sdk/ruby/lib/rockbox/api/devices.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class Devices 8 + FIELDS = "id name host ip port service app isConnected baseUrl isCastDevice isSourceDevice isCurrentDevice" 9 + 10 + def initialize(http) 11 + @http = http 12 + end 13 + 14 + def list 15 + data = @http.execute("query Devices { devices { #{FIELDS} } }") 16 + Array(data[:devices]).map { |d| Device.from_hash(d) } 17 + end 18 + 19 + def get(id) 20 + data = @http.execute( 21 + "query Device($id: String!) { device(id: $id) { #{FIELDS} } }", 22 + { id: id } 23 + ) 24 + Device.from_hash(data[:device]) 25 + end 26 + 27 + def connect(id) 28 + @http.execute("mutation ConnectDevice($id: String!) { connect(id: $id) }", { id: id }) 29 + nil 30 + end 31 + 32 + def disconnect(id) 33 + @http.execute("mutation DisconnectDevice($id: String!) { disconnect(id: $id) }", { id: id }) 34 + nil 35 + end 36 + end 37 + end 38 + end
+167
sdk/ruby/lib/rockbox/api/library.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class Library 8 + TRACK_FIELDS = <<~GQL 9 + fragment TrackFields on Track { 10 + id title artist album genre disc trackString yearString 11 + composer comment albumArtist grouping 12 + discnum tracknum layer year bitrate frequency 13 + filesize length elapsed path 14 + albumId artistId genreId albumArt 15 + } 16 + GQL 17 + 18 + ALBUM_FIELDS = <<~GQL 19 + fragment AlbumFields on Album { 20 + id title artist year yearString albumArt md5 artistId copyrightMessage 21 + } 22 + GQL 23 + 24 + ARTIST_FIELDS = <<~GQL 25 + fragment ArtistFields on Artist { id name bio image } 26 + GQL 27 + 28 + def initialize(http) 29 + @http = http 30 + end 31 + 32 + # --------------------------------------------------------------------- 33 + # Albums 34 + # --------------------------------------------------------------------- 35 + 36 + def albums 37 + data = @http.execute(<<~GQL) 38 + #{ALBUM_FIELDS} 39 + query Albums { albums { ...AlbumFields tracks { id title path length albumArt } } } 40 + GQL 41 + Array(data[:albums]).map { |a| Album.from_hash(a) } 42 + end 43 + 44 + def album(id) 45 + data = @http.execute(<<~GQL, { id: id }) 46 + #{TRACK_FIELDS} 47 + #{ALBUM_FIELDS} 48 + query Album($id: String!) { album(id: $id) { ...AlbumFields tracks { ...TrackFields } } } 49 + GQL 50 + Album.from_hash(data[:album]) 51 + end 52 + 53 + def liked_albums 54 + data = @http.execute("#{ALBUM_FIELDS}\nquery LikedAlbums { likedAlbums { ...AlbumFields } }") 55 + Array(data[:liked_albums]).map { |a| Album.from_hash(a) } 56 + end 57 + 58 + def like_album(id) 59 + @http.execute("mutation LikeAlbum($id: String!) { likeAlbum(id: $id) }", { id: id }) 60 + nil 61 + end 62 + 63 + def unlike_album(id) 64 + @http.execute("mutation UnlikeAlbum($id: String!) { unlikeAlbum(id: $id) }", { id: id }) 65 + nil 66 + end 67 + 68 + # --------------------------------------------------------------------- 69 + # Artists 70 + # --------------------------------------------------------------------- 71 + 72 + def artists 73 + data = @http.execute(<<~GQL) 74 + #{ARTIST_FIELDS} 75 + query Artists { artists { ...ArtistFields albums { id title albumArt year } } } 76 + GQL 77 + Array(data[:artists]).map { |a| Artist.from_hash(a) } 78 + end 79 + 80 + def artist(id) 81 + data = @http.execute(<<~GQL, { id: id }) 82 + #{ARTIST_FIELDS} 83 + #{TRACK_FIELDS} 84 + query Artist($id: String!) { 85 + artist(id: $id) { 86 + ...ArtistFields 87 + albums { id title albumArt year yearString md5 artistId tracks { id title path length } } 88 + tracks { ...TrackFields } 89 + } 90 + } 91 + GQL 92 + Artist.from_hash(data[:artist]) 93 + end 94 + 95 + # --------------------------------------------------------------------- 96 + # Tracks 97 + # --------------------------------------------------------------------- 98 + 99 + def tracks 100 + data = @http.execute("#{TRACK_FIELDS}\nquery Tracks { tracks { ...TrackFields } }") 101 + Array(data[:tracks]).map { |t| Track.from_hash(t) } 102 + end 103 + 104 + def track(id) 105 + data = @http.execute( 106 + "#{TRACK_FIELDS}\nquery Track($id: String!) { track(id: $id) { ...TrackFields } }", 107 + { id: id } 108 + ) 109 + Track.from_hash(data[:track]) 110 + end 111 + 112 + def liked_tracks 113 + data = @http.execute("#{TRACK_FIELDS}\nquery LikedTracks { likedTracks { ...TrackFields } }") 114 + Array(data[:liked_tracks]).map { |t| Track.from_hash(t) } 115 + end 116 + 117 + def like_track(id) 118 + @http.execute("mutation LikeTrack($id: String!) { likeTrack(id: $id) }", { id: id }) 119 + nil 120 + end 121 + 122 + def unlike_track(id) 123 + @http.execute("mutation UnlikeTrack($id: String!) { unlikeTrack(id: $id) }", { id: id }) 124 + nil 125 + end 126 + 127 + # --------------------------------------------------------------------- 128 + # Search 129 + # --------------------------------------------------------------------- 130 + 131 + # @return [Rockbox::SearchResults] 132 + def search(term) 133 + data = @http.execute(<<~GQL, { term: term }) 134 + #{TRACK_FIELDS} 135 + #{ALBUM_FIELDS} 136 + #{ARTIST_FIELDS} 137 + query Search($term: String!) { 138 + search(term: $term) { 139 + artists { ...ArtistFields } 140 + albums { ...AlbumFields } 141 + tracks { ...TrackFields } 142 + likedTracks { ...TrackFields } 143 + likedAlbums { ...AlbumFields } 144 + } 145 + } 146 + GQL 147 + results = data[:search] || {} 148 + SearchResults.new( 149 + artists: Array(results[:artists]).map { |a| Artist.from_hash(a) }, 150 + albums: Array(results[:albums]).map { |a| Album.from_hash(a) }, 151 + tracks: Array(results[:tracks]).map { |t| Track.from_hash(t) }, 152 + liked_tracks: Array(results[:liked_tracks]).map { |t| Track.from_hash(t) }, 153 + liked_albums: Array(results[:liked_albums]).map { |a| Album.from_hash(a) } 154 + ) 155 + end 156 + 157 + # --------------------------------------------------------------------- 158 + # Library management 159 + # --------------------------------------------------------------------- 160 + 161 + def scan 162 + @http.execute("mutation ScanLibrary { scanLibrary }") 163 + nil 164 + end 165 + end 166 + end 167 + end
+148
sdk/ruby/lib/rockbox/api/playback.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class Playback 8 + TRACK_FIELDS = <<~GQL 9 + fragment TrackFields on Track { 10 + id title artist album genre disc trackString yearString 11 + composer comment albumArtist grouping 12 + discnum tracknum layer year bitrate frequency 13 + filesize length elapsed path 14 + albumId artistId genreId albumArt 15 + } 16 + GQL 17 + 18 + def initialize(http) 19 + @http = http 20 + end 21 + 22 + # --------------------------------------------------------------------- 23 + # Status & current track 24 + # --------------------------------------------------------------------- 25 + 26 + # @return [Integer] one of {Rockbox::PlaybackStatus} constants. 27 + def status 28 + @http.execute("query PlaybackStatus { status }")[:status] 29 + end 30 + 31 + # @return [Symbol] :stopped | :playing | :paused | :unknown 32 + def status_name 33 + PlaybackStatus.name(status) 34 + end 35 + 36 + # @return [Rockbox::Track, nil] 37 + def current_track 38 + data = @http.execute("#{TRACK_FIELDS}\nquery CurrentTrack { currentTrack { ...TrackFields } }") 39 + Track.from_hash(data[:current_track]) 40 + end 41 + 42 + # @return [Rockbox::Track, nil] 43 + def next_track 44 + data = @http.execute("#{TRACK_FIELDS}\nquery NextTrack { nextTrack { ...TrackFields } }") 45 + Track.from_hash(data[:next_track]) 46 + end 47 + 48 + # @return [Integer] 49 + def file_position 50 + @http.execute("query FilePosition { getFilePosition }")[:get_file_position] 51 + end 52 + 53 + # --------------------------------------------------------------------- 54 + # Transport controls 55 + # --------------------------------------------------------------------- 56 + 57 + def play(elapsed: 0, offset: 0) 58 + @http.execute( 59 + "mutation Play($elapsed: Long!, $offset: Long!) { play(elapsed: $elapsed, offset: $offset) }", 60 + { elapsed: elapsed, offset: offset } 61 + ) 62 + nil 63 + end 64 + 65 + def pause; @http.execute("mutation Pause { pause }"); nil; end 66 + def resume; @http.execute("mutation Resume { resume }"); nil; end 67 + def next!; @http.execute("mutation Next { next }"); nil; end 68 + def previous!; @http.execute("mutation Previous { previous }"); nil; end 69 + def stop; @http.execute("mutation Stop { hardStop }"); nil; end 70 + def flush_and_reload; @http.execute("mutation FlushReload { flushAndReloadTracks }"); nil; end 71 + 72 + # @param position_ms [Integer] absolute target position, in milliseconds. 73 + def seek(position_ms) 74 + @http.execute( 75 + "mutation Seek($newTime: Int!) { fastForwardRewind(newTime: $newTime) }", 76 + { new_time: position_ms } 77 + ) 78 + nil 79 + end 80 + 81 + # --------------------------------------------------------------------- 82 + # Single-call play helpers 83 + # --------------------------------------------------------------------- 84 + 85 + def play_track(path) 86 + @http.execute( 87 + "mutation PlayTrack($path: String!) { playTrack(path: $path) }", 88 + { path: path } 89 + ) 90 + nil 91 + end 92 + 93 + def play_album(album_id, shuffle: nil, position: nil) 94 + @http.execute( 95 + "mutation PlayAlbum($albumId: String!, $shuffle: Boolean, $position: Int) { " \ 96 + "playAlbum(albumId: $albumId, shuffle: $shuffle, position: $position) }", 97 + { album_id: album_id, shuffle: shuffle, position: position }.compact 98 + ) 99 + nil 100 + end 101 + 102 + def play_artist(artist_id, shuffle: nil, position: nil) 103 + @http.execute( 104 + "mutation PlayArtist($artistId: String!, $shuffle: Boolean, $position: Int) { " \ 105 + "playArtistTracks(artistId: $artistId, shuffle: $shuffle, position: $position) }", 106 + { artist_id: artist_id, shuffle: shuffle, position: position }.compact 107 + ) 108 + nil 109 + end 110 + 111 + def play_playlist(playlist_id, shuffle: nil, position: nil) 112 + @http.execute( 113 + "mutation PlayPlaylist($playlistId: String!, $shuffle: Boolean, $position: Int) { " \ 114 + "playPlaylist(playlistId: $playlistId, shuffle: $shuffle, position: $position) }", 115 + { playlist_id: playlist_id, shuffle: shuffle, position: position }.compact 116 + ) 117 + nil 118 + end 119 + 120 + def play_directory(path, recurse: nil, shuffle: nil, position: nil) 121 + @http.execute( 122 + "mutation PlayDirectory($path: String!, $recurse: Boolean, $shuffle: Boolean, $position: Int) { " \ 123 + "playDirectory(path: $path, recurse: $recurse, shuffle: $shuffle, position: $position) }", 124 + { path: path, recurse: recurse, shuffle: shuffle, position: position }.compact 125 + ) 126 + nil 127 + end 128 + 129 + def play_liked_tracks(shuffle: nil, position: nil) 130 + @http.execute( 131 + "mutation PlayLikedTracks($shuffle: Boolean, $position: Int) { " \ 132 + "playLikedTracks(shuffle: $shuffle, position: $position) }", 133 + { shuffle: shuffle, position: position }.compact 134 + ) 135 + nil 136 + end 137 + 138 + def play_all_tracks(shuffle: nil, position: nil) 139 + @http.execute( 140 + "mutation PlayAllTracks($shuffle: Boolean, $position: Int) { " \ 141 + "playAllTracks(shuffle: $shuffle, position: $position) }", 142 + { shuffle: shuffle, position: position }.compact 143 + ) 144 + nil 145 + end 146 + end 147 + end 148 + end
+127
sdk/ruby/lib/rockbox/api/playlist.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class Playlist 8 + TRACK_FIELDS = <<~GQL 9 + fragment TrackFields on Track { 10 + id title artist album genre disc trackString yearString 11 + composer comment albumArtist grouping 12 + discnum tracknum layer year bitrate frequency 13 + filesize length elapsed path 14 + albumId artistId genreId albumArt 15 + } 16 + GQL 17 + 18 + def initialize(http) 19 + @http = http 20 + end 21 + 22 + # @return [Rockbox::Playlist] 23 + def current 24 + data = @http.execute(<<~GQL) 25 + #{TRACK_FIELDS} 26 + query CurrentPlaylist { 27 + playlistGetCurrent { 28 + amount index maxPlaylistSize firstIndex 29 + lastInsertPos seed lastShuffledStart 30 + tracks { ...TrackFields } 31 + } 32 + } 33 + GQL 34 + pl = data[:playlist_get_current] || {} 35 + Rockbox::Playlist.new( 36 + amount: pl[:amount], 37 + index: pl[:index], 38 + max_playlist_size: pl[:max_playlist_size], 39 + first_index: pl[:first_index], 40 + last_insert_pos: pl[:last_insert_pos], 41 + seed: pl[:seed], 42 + last_shuffled_start: pl[:last_shuffled_start], 43 + tracks: Array(pl[:tracks]).map { |t| Track.from_hash(t) } 44 + ) 45 + end 46 + 47 + def amount 48 + @http.execute("query PlaylistAmount { playlistAmount }")[:playlist_amount] 49 + end 50 + 51 + # --------------------------------------------------------------------- 52 + # Queue management 53 + # --------------------------------------------------------------------- 54 + 55 + # @param paths [Array<String>] file paths or track IDs to insert. 56 + # @param position [Integer] one of {Rockbox::InsertPosition} (default: NEXT). 57 + # @param playlist_id [String, nil] target playlist; nil for the active queue. 58 + def insert_tracks(paths, position: InsertPosition::NEXT, playlist_id: nil) 59 + @http.execute( 60 + "mutation InsertTracks($playlistId: String, $position: Int!, $tracks: [String!]!) { " \ 61 + "insertTracks(playlistId: $playlistId, position: $position, tracks: $tracks) }", 62 + { playlist_id: playlist_id, position: position, tracks: paths } 63 + ) 64 + nil 65 + end 66 + 67 + def insert_directory(directory, position: InsertPosition::LAST, playlist_id: nil) 68 + @http.execute( 69 + "mutation InsertDirectory($playlistId: String, $position: Int!, $directory: String!) { " \ 70 + "insertDirectory(playlistId: $playlistId, position: $position, directory: $directory) }", 71 + { playlist_id: playlist_id, position: position, directory: directory } 72 + ) 73 + nil 74 + end 75 + 76 + def insert_album(album_id, position: InsertPosition::LAST) 77 + @http.execute( 78 + "mutation InsertAlbum($albumId: String!, $position: Int!) { " \ 79 + "insertAlbum(albumId: $albumId, position: $position) }", 80 + { album_id: album_id, position: position } 81 + ) 82 + nil 83 + end 84 + 85 + def remove_track(index) 86 + @http.execute( 87 + "mutation RemoveTrack($index: Int!) { playlistRemoveTrack(index: $index) }", 88 + { index: index } 89 + ) 90 + nil 91 + end 92 + 93 + def clear 94 + @http.execute("mutation ClearPlaylist { playlistRemoveAllTracks }") 95 + nil 96 + end 97 + 98 + def shuffle 99 + @http.execute("mutation ShufflePlaylist { shufflePlaylist }") 100 + nil 101 + end 102 + 103 + def create(name, tracks) 104 + @http.execute( 105 + "mutation CreatePlaylist($name: String!, $tracks: [String!]!) { " \ 106 + "playlistCreate(name: $name, tracks: $tracks) }", 107 + { name: name, tracks: tracks } 108 + ) 109 + nil 110 + end 111 + 112 + def start(start_index: nil, elapsed: nil, offset: nil) 113 + @http.execute( 114 + "mutation PlaylistStart($startIndex: Int, $elapsed: Int, $offset: Int) { " \ 115 + "playlistStart(startIndex: $startIndex, elapsed: $elapsed, offset: $offset) }", 116 + { start_index: start_index, elapsed: elapsed, offset: offset }.compact 117 + ) 118 + nil 119 + end 120 + 121 + def resume 122 + @http.execute("mutation PlaylistResume { playlistResume }") 123 + nil 124 + end 125 + end 126 + end 127 + end
+163
sdk/ruby/lib/rockbox/api/saved_playlists.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class SavedPlaylists 8 + def initialize(http) 9 + @http = http 10 + end 11 + 12 + def list(folder_id: nil) 13 + data = @http.execute( 14 + "query SavedPlaylists($folderId: String) { " \ 15 + "savedPlaylists(folderId: $folderId) { id name description image folderId trackCount createdAt updatedAt } }", 16 + { folder_id: folder_id }.compact 17 + ) 18 + Array(data[:saved_playlists]).map { |p| SavedPlaylist.from_hash(p) } 19 + end 20 + 21 + def get(id) 22 + data = @http.execute( 23 + "query SavedPlaylist($id: String!) { " \ 24 + "savedPlaylist(id: $id) { id name description image folderId trackCount createdAt updatedAt } }", 25 + { id: id } 26 + ) 27 + SavedPlaylist.from_hash(data[:saved_playlist]) 28 + end 29 + 30 + def track_ids(playlist_id) 31 + @http.execute( 32 + "query SavedPlaylistTrackIds($playlistId: String!) { savedPlaylistTrackIds(playlistId: $playlistId) }", 33 + { playlist_id: playlist_id } 34 + )[:saved_playlist_track_ids] || [] 35 + end 36 + 37 + # @example Builder-friendly 38 + # client.saved_playlists.create(name: "Late nights") do |p| 39 + # p.description = "After-dark vibes" 40 + # p.track_ids = ["abc", "def"] 41 + # end 42 + def create(name:, description: nil, image: nil, folder_id: nil, track_ids: nil) 43 + builder = CreateBuilder.new(name, description, image, folder_id, track_ids) 44 + yield builder if block_given? 45 + 46 + data = @http.execute(<<~GQL, builder.to_variables) 47 + mutation CreateSavedPlaylist( 48 + $name: String!, $description: String, $image: String, 49 + $folderId: String, $trackIds: [String!] 50 + ) { 51 + createSavedPlaylist( 52 + name: $name, description: $description, image: $image, 53 + folderId: $folderId, trackIds: $trackIds 54 + ) { 55 + id name description image folderId trackCount createdAt updatedAt 56 + } 57 + } 58 + GQL 59 + SavedPlaylist.from_hash(data[:create_saved_playlist]) 60 + end 61 + 62 + # @example 63 + # client.saved_playlists.update("pl_123", name: "Renamed") 64 + def update(id, name:, description: nil, image: nil, folder_id: nil) 65 + @http.execute(<<~GQL, { id: id, name: name, description: description, image: image, folder_id: folder_id }.compact) 66 + mutation UpdateSavedPlaylist( 67 + $id: String!, $name: String!, $description: String, 68 + $image: String, $folderId: String 69 + ) { 70 + updateSavedPlaylist( 71 + id: $id, name: $name, description: $description, 72 + image: $image, folderId: $folderId 73 + ) 74 + } 75 + GQL 76 + nil 77 + end 78 + 79 + def delete(id) 80 + @http.execute( 81 + "mutation DeleteSavedPlaylist($id: String!) { deleteSavedPlaylist(id: $id) }", 82 + { id: id } 83 + ) 84 + nil 85 + end 86 + 87 + def add_tracks(playlist_id, track_ids) 88 + @http.execute( 89 + "mutation AddTracksToSavedPlaylist($playlistId: String!, $trackIds: [String!]!) { " \ 90 + "addTracksToSavedPlaylist(playlistId: $playlistId, trackIds: $trackIds) }", 91 + { playlist_id: playlist_id, track_ids: track_ids } 92 + ) 93 + nil 94 + end 95 + 96 + def remove_track(playlist_id, track_id) 97 + @http.execute( 98 + "mutation RemoveTrackFromSavedPlaylist($playlistId: String!, $trackId: String!) { " \ 99 + "removeTrackFromSavedPlaylist(playlistId: $playlistId, trackId: $trackId) }", 100 + { playlist_id: playlist_id, track_id: track_id } 101 + ) 102 + nil 103 + end 104 + 105 + def play(playlist_id) 106 + @http.execute( 107 + "mutation PlaySavedPlaylist($playlistId: String!) { playSavedPlaylist(playlistId: $playlistId) }", 108 + { playlist_id: playlist_id } 109 + ) 110 + nil 111 + end 112 + 113 + # --------------------------------------------------------------------- 114 + # Folders 115 + # --------------------------------------------------------------------- 116 + 117 + def folders 118 + data = @http.execute("query PlaylistFolders { playlistFolders { id name createdAt updatedAt } }") 119 + Array(data[:playlist_folders]).map { |f| SavedPlaylistFolder.from_hash(f) } 120 + end 121 + 122 + def create_folder(name) 123 + data = @http.execute( 124 + "mutation CreatePlaylistFolder($name: String!) { " \ 125 + "createPlaylistFolder(name: $name) { id name createdAt updatedAt } }", 126 + { name: name } 127 + ) 128 + SavedPlaylistFolder.from_hash(data[:create_playlist_folder]) 129 + end 130 + 131 + def delete_folder(id) 132 + @http.execute( 133 + "mutation DeletePlaylistFolder($id: String!) { deletePlaylistFolder(id: $id) }", 134 + { id: id } 135 + ) 136 + nil 137 + end 138 + 139 + # Builder for #create — supports the optional yield-block DSL. 140 + class CreateBuilder 141 + attr_accessor :name, :description, :image, :folder_id, :track_ids 142 + 143 + def initialize(name, description, image, folder_id, track_ids) 144 + @name = name 145 + @description = description 146 + @image = image 147 + @folder_id = folder_id 148 + @track_ids = track_ids 149 + end 150 + 151 + def to_variables 152 + { 153 + name: name, 154 + description: description, 155 + image: image, 156 + folder_id: folder_id, 157 + track_ids: track_ids 158 + }.compact 159 + end 160 + end 161 + end 162 + end 163 + end
+118
sdk/ruby/lib/rockbox/api/settings.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class Settings 8 + QUERY = <<~GQL 9 + query GlobalSettings { 10 + globalSettings { 11 + musicDir volume balance bass treble channelConfig stereoWidth 12 + eqEnabled eqPrecut 13 + eqBandSettings { cutoff q gain } 14 + replaygainSettings { noclip type preamp } 15 + compressorSettings { threshold makeupGain ratio knee releaseTime attackTime } 16 + crossfadeEnabled crossfadeFadeInDelay crossfadeFadeInDuration 17 + crossfadeFadeOutDelay crossfadeFadeOutDuration crossfadeFadeOutMixmode 18 + crossfeedEnabled crossfeedDirectGain crossfeedCrossGain 19 + crossfeedHfAttenuation crossfeedHfCutoff 20 + repeatMode singleMode partyMode shuffle playerName 21 + } 22 + } 23 + GQL 24 + 25 + def initialize(http) 26 + @http = http 27 + end 28 + 29 + # @return [Rockbox::UserSettings] 30 + def get 31 + s = @http.execute(QUERY)[:global_settings] || {} 32 + 33 + UserSettings.new( 34 + music_dir: s[:music_dir], 35 + volume: s[:volume], 36 + balance: s[:balance], 37 + bass: s[:bass], 38 + treble: s[:treble], 39 + channel_config: s[:channel_config], 40 + stereo_width: s[:stereo_width], 41 + eq_enabled: s[:eq_enabled], 42 + eq_precut: s[:eq_precut], 43 + eq_band_settings: Array(s[:eq_band_settings]).map { |b| EqBandSetting.from_hash(b) }, 44 + replaygain_settings: ReplaygainSettings.from_hash(s[:replaygain_settings]), 45 + compressor_settings: CompressorSettings.from_hash(s[:compressor_settings]), 46 + crossfade_enabled: s[:crossfade_enabled], 47 + crossfade_fade_in_delay: s[:crossfade_fade_in_delay], 48 + crossfade_fade_in_duration: s[:crossfade_fade_in_duration], 49 + crossfade_fade_out_delay: s[:crossfade_fade_out_delay], 50 + crossfade_fade_out_duration: s[:crossfade_fade_out_duration], 51 + crossfade_fade_out_mixmode: s[:crossfade_fade_out_mixmode], 52 + crossfeed_enabled: s[:crossfeed_enabled], 53 + crossfeed_direct_gain: s[:crossfeed_direct_gain], 54 + crossfeed_cross_gain: s[:crossfeed_cross_gain], 55 + crossfeed_hf_attenuation: s[:crossfeed_hf_attenuation], 56 + crossfeed_hf_cutoff: s[:crossfeed_hf_cutoff], 57 + repeat_mode: s[:repeat_mode], 58 + single_mode: s[:single_mode], 59 + party_mode: s[:party_mode], 60 + shuffle: s[:shuffle], 61 + player_name: s[:player_name] 62 + ) 63 + end 64 + 65 + # Save a partial settings update. Pass any subset of keys; everything 66 + # else is left as-is by the firmware. 67 + # 68 + # @example Builder block 69 + # client.settings.save do |s| 70 + # s.volume = -20 71 + # s.bass = 4 72 + # s.shuffle = true 73 + # end 74 + # 75 + # @example Hash 76 + # client.settings.save(volume: -20, bass: 4) 77 + def save(settings = nil, &block) 78 + if block 79 + builder = SaveBuilder.new 80 + yield builder 81 + settings = builder.to_h.merge(settings || {}) 82 + end 83 + raise ArgumentError, "settings hash or block required" if settings.nil? || settings.empty? 84 + 85 + @http.execute( 86 + "mutation SaveSettings($settings: NewGlobalSettings!) { saveSettings(settings: $settings) }", 87 + { settings: settings } 88 + ) 89 + nil 90 + end 91 + 92 + class SaveBuilder 93 + SETTABLE = %i[ 94 + music_dir volume balance bass treble channel_config stereo_width 95 + eq_enabled eq_precut eq_band_settings replaygain_settings compressor_settings 96 + crossfade_enabled crossfade_fade_in_delay crossfade_fade_in_duration 97 + crossfade_fade_out_delay crossfade_fade_out_duration crossfade_fade_out_mixmode 98 + crossfeed_enabled crossfeed_direct_gain crossfeed_cross_gain 99 + crossfeed_hf_attenuation crossfeed_hf_cutoff 100 + repeat_mode single_mode party_mode shuffle player_name 101 + ].freeze 102 + 103 + def initialize 104 + @attrs = {} 105 + end 106 + 107 + SETTABLE.each do |attr| 108 + define_method("#{attr}=") { |value| @attrs[attr] = value } 109 + define_method(attr) { @attrs[attr] } 110 + end 111 + 112 + def to_h 113 + @attrs.dup 114 + end 115 + end 116 + end 117 + end 118 + end
+115
sdk/ruby/lib/rockbox/api/smart_playlists.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class SmartPlaylists 8 + def initialize(http) 9 + @http = http 10 + end 11 + 12 + def list 13 + data = @http.execute(<<~GQL) 14 + query SmartPlaylists { 15 + smartPlaylists { id name description image folderId isSystem rules createdAt updatedAt } 16 + } 17 + GQL 18 + Array(data[:smart_playlists]).map { |p| SmartPlaylist.from_hash(p) } 19 + end 20 + 21 + def get(id) 22 + data = @http.execute( 23 + "query SmartPlaylist($id: String!) { " \ 24 + "smartPlaylist(id: $id) { id name description image folderId isSystem rules createdAt updatedAt } }", 25 + { id: id } 26 + ) 27 + SmartPlaylist.from_hash(data[:smart_playlist]) 28 + end 29 + 30 + def track_ids(id) 31 + @http.execute( 32 + "query SmartPlaylistTrackIds($id: String!) { smartPlaylistTrackIds(id: $id) }", 33 + { id: id } 34 + )[:smart_playlist_track_ids] || [] 35 + end 36 + 37 + # @example 38 + # client.smart_playlists.create(name: "Heavy hitters", rules: rules_json) 39 + def create(name:, rules:, description: nil, image: nil, folder_id: nil) 40 + vars = { name: name, rules: rules, description: description, 41 + image: image, folder_id: folder_id }.compact 42 + data = @http.execute(<<~GQL, vars) 43 + mutation CreateSmartPlaylist( 44 + $name: String!, $rules: String!, $description: String, 45 + $image: String, $folderId: String 46 + ) { 47 + createSmartPlaylist( 48 + name: $name, rules: $rules, description: $description, 49 + image: $image, folderId: $folderId 50 + ) { id name description image folderId isSystem rules createdAt updatedAt } 51 + } 52 + GQL 53 + SmartPlaylist.from_hash(data[:create_smart_playlist]) 54 + end 55 + 56 + def update(id, name:, rules:, description: nil, image: nil, folder_id: nil) 57 + vars = { id: id, name: name, rules: rules, description: description, 58 + image: image, folder_id: folder_id }.compact 59 + @http.execute(<<~GQL, vars) 60 + mutation UpdateSmartPlaylist( 61 + $id: String!, $name: String!, $rules: String!, 62 + $description: String, $image: String, $folderId: String 63 + ) { 64 + updateSmartPlaylist( 65 + id: $id, name: $name, rules: $rules, 66 + description: $description, image: $image, folderId: $folderId 67 + ) 68 + } 69 + GQL 70 + nil 71 + end 72 + 73 + def delete(id) 74 + @http.execute("mutation DeleteSmartPlaylist($id: String!) { deleteSmartPlaylist(id: $id) }", { id: id }) 75 + nil 76 + end 77 + 78 + def play(id) 79 + @http.execute("mutation PlaySmartPlaylist($id: String!) { playSmartPlaylist(id: $id) }", { id: id }) 80 + nil 81 + end 82 + 83 + # --------------------------------------------------------------------- 84 + # Listening stats 85 + # --------------------------------------------------------------------- 86 + 87 + def track_stats(track_id) 88 + data = @http.execute(<<~GQL, { track_id: track_id }) 89 + query TrackStats($trackId: String!) { 90 + trackStats(trackId: $trackId) { 91 + trackId playCount skipCount lastPlayed lastSkipped updatedAt 92 + } 93 + } 94 + GQL 95 + TrackStats.from_hash(data[:track_stats]) 96 + end 97 + 98 + def record_played(track_id) 99 + @http.execute( 100 + "mutation RecordTrackPlayed($trackId: String!) { recordTrackPlayed(trackId: $trackId) }", 101 + { track_id: track_id } 102 + ) 103 + nil 104 + end 105 + 106 + def record_skipped(track_id) 107 + @http.execute( 108 + "mutation RecordTrackSkipped($trackId: String!) { recordTrackSkipped(trackId: $trackId) }", 109 + { track_id: track_id } 110 + ) 111 + nil 112 + end 113 + end 114 + end 115 + end
+31
sdk/ruby/lib/rockbox/api/sound.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class Sound 8 + def initialize(http) 9 + @http = http 10 + end 11 + 12 + # @return [Rockbox::VolumeInfo] 13 + def volume 14 + data = @http.execute("query Volume { volume { volume min max } }") 15 + VolumeInfo.from_hash(data[:volume]) 16 + end 17 + 18 + # Adjust volume by N steps (positive = louder, negative = quieter). 19 + # @return [Integer] resulting volume 20 + def adjust(steps) 21 + @http.execute( 22 + "mutation AdjustVolume($steps: Int!) { adjustVolume(steps: $steps) }", 23 + { steps: steps } 24 + )[:adjust_volume] 25 + end 26 + 27 + def up; adjust(1); end 28 + def down; adjust(-1); end 29 + end 30 + end 31 + end
+32
sdk/ruby/lib/rockbox/api/system.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "../types" 4 + 5 + module Rockbox 6 + module Api 7 + class System 8 + def initialize(http) 9 + @http = http 10 + end 11 + 12 + # @return [String] 13 + def version 14 + @http.execute("query Version { rockboxVersion }")[:rockbox_version] 15 + end 16 + 17 + # @return [Rockbox::SystemStatus] 18 + def status 19 + data = @http.execute(<<~GQL) 20 + query GlobalStatus { 21 + globalStatus { 22 + resumeIndex resumeCrc32 resumeElapsed resumeOffset 23 + runtime topruntime dircacheSize 24 + lastScreen viewerIconCount lastVolumeChange 25 + } 26 + } 27 + GQL 28 + SystemStatus.from_hash(data[:global_status]) 29 + end 30 + end 31 + end 32 + end
+43
sdk/ruby/lib/rockbox/case_conversion.rb
··· 1 + # frozen_string_literal: true 2 + 3 + module Rockbox 4 + # Convert between GraphQL camelCase JSON keys and Ruby snake_case symbols. 5 + # The transport applies these on every request/response so user code can 6 + # always work in idiomatic Ruby. 7 + module CaseConversion 8 + module_function 9 + 10 + def camelize(str) 11 + head, *tail = str.to_s.split("_") 12 + ([head] + tail.map(&:capitalize)).join 13 + end 14 + 15 + def snakeize(str) 16 + str.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase 17 + end 18 + 19 + # Recursively rewrite a Hash/Array — used on outgoing variable payloads. 20 + def deep_camelize(obj) 21 + case obj 22 + when Hash 23 + obj.each_with_object({}) { |(k, v), h| h[camelize(k)] = deep_camelize(v) } 24 + when Array 25 + obj.map { |v| deep_camelize(v) } 26 + else 27 + obj 28 + end 29 + end 30 + 31 + # Recursively rewrite a Hash/Array — used on incoming GraphQL responses. 32 + def deep_snakeize(obj) 33 + case obj 34 + when Hash 35 + obj.each_with_object({}) { |(k, v), h| h[snakeize(k).to_sym] = deep_snakeize(v) } 36 + when Array 37 + obj.map { |v| deep_snakeize(v) } 38 + else 39 + obj 40 + end 41 + end 42 + end 43 + end
+226
sdk/ruby/lib/rockbox/client.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "configuration" 4 + require_relative "transport" 5 + require_relative "events" 6 + require_relative "plugin" 7 + require_relative "types" 8 + 9 + require_relative "api/playback" 10 + require_relative "api/library" 11 + require_relative "api/playlist" 12 + require_relative "api/saved_playlists" 13 + require_relative "api/smart_playlists" 14 + require_relative "api/sound" 15 + require_relative "api/settings" 16 + require_relative "api/system" 17 + require_relative "api/browse" 18 + require_relative "api/devices" 19 + require_relative "api/bluetooth" 20 + 21 + module Rockbox 22 + # --------------------------------------------------------------------------- 23 + # Rockbox::Client — main entry point. 24 + # 25 + # Inspired by: 26 + # Mopidy — domain namespace API (client.playback.play, client.library.search) 27 + # Jellyfin — plugin install/uninstall lifecycle 28 + # Kodi — rich device + playlist management 29 + # 30 + # @example Quick start 31 + # client = Rockbox::Client.new 32 + # client.connect # start WebSocket subscriptions 33 + # 34 + # client.on(:track_changed) { |track| puts "Now playing: #{track.title}" } 35 + # 36 + # results = client.library.search("dark side") 37 + # client.playback.play_album(results.albums.first.id, shuffle: true) 38 + # 39 + # @example Builder DSL 40 + # client = Rockbox::Client.build do |c| 41 + # c.host = "192.168.1.42" 42 + # c.port = 6062 43 + # end 44 + # --------------------------------------------------------------------------- 45 + class Client 46 + attr_reader :playback, :library, :playlist, :saved_playlists, :smart_playlists, 47 + :sound, :settings, :system, :browse, :devices, :bluetooth, 48 + :configuration 49 + 50 + # Block-form constructor — yields a {Configuration} for tweaking. 51 + def self.build 52 + config = Configuration.new 53 + yield config if block_given? 54 + new(configuration: config) 55 + end 56 + 57 + # @param host [String] hostname or IP of rockboxd (default: "localhost") 58 + # @param port [Integer] GraphQL port (default: 6062) 59 + # @param http_url [String] override the full HTTP URL 60 + # @param ws_url [String] override the full WebSocket URL 61 + # @param open_timeout [Integer] HTTP connect timeout (seconds) 62 + # @param read_timeout [Integer] HTTP read timeout (seconds) 63 + # @param configuration [Configuration] pre-built configuration (rare) 64 + def initialize(host: nil, port: nil, http_url: nil, ws_url: nil, 65 + open_timeout: nil, read_timeout: nil, configuration: nil) 66 + @configuration = configuration || Configuration.new( 67 + host: host, port: port, 68 + http_url: http_url, ws_url: ws_url, 69 + open_timeout: open_timeout, read_timeout: read_timeout 70 + ) 71 + 72 + @http = HttpTransport.new( 73 + @configuration.resolved_http_url, 74 + open_timeout: @configuration.open_timeout || HttpTransport::DEFAULT_OPEN_TIMEOUT, 75 + read_timeout: @configuration.read_timeout || HttpTransport::DEFAULT_READ_TIMEOUT 76 + ) 77 + @ws = WsTransport.new(@configuration.resolved_ws_url) 78 + 79 + @events = EventEmitter.new 80 + @plugins = PluginRegistry.new 81 + @subscriptions = [] 82 + 83 + @playback = Api::Playback.new(@http) 84 + @library = Api::Library.new(@http) 85 + @playlist = Api::Playlist.new(@http) 86 + @saved_playlists = Api::SavedPlaylists.new(@http) 87 + @smart_playlists = Api::SmartPlaylists.new(@http) 88 + @sound = Api::Sound.new(@http) 89 + @settings = Api::Settings.new(@http) 90 + @system = Api::System.new(@http) 91 + @browse = Api::Browse.new(@http) 92 + @devices = Api::Devices.new(@http) 93 + @bluetooth = Api::Bluetooth.new(@http) 94 + end 95 + 96 + # --------------------------------------------------------------------------- 97 + # Events — block-friendly delegation to the EventEmitter 98 + # --------------------------------------------------------------------------- 99 + 100 + def on(event, &block); @events.on(event, &block); self; end 101 + def once(event, &block); @events.once(event, &block); self; end 102 + def off(event, listener = nil, &block); @events.off(event, listener, &block); self; end 103 + def emit(event, payload = nil); @events.emit(event, payload); self; end 104 + def remove_all_listeners(event = nil); @events.remove_all_listeners(event); self; end 105 + 106 + # --------------------------------------------------------------------------- 107 + # Real-time subscriptions 108 + # --------------------------------------------------------------------------- 109 + 110 + # Open the WebSocket and subscribe to the three default streams. Idempotent. 111 + # 112 + # @return [self] 113 + def connect 114 + return self unless @subscriptions.empty? 115 + 116 + @subscriptions << @ws.subscribe( 117 + <<~GQL, nil, 118 + subscription CurrentlyPlaying { 119 + currentlyPlayingSong { 120 + id title artist album albumArt albumId artistId path length elapsed 121 + } 122 + } 123 + GQL 124 + next: ->(result) { 125 + payload = result[:data]&.dig(:currently_playing_song) 126 + @events.emit(:track_changed, Track.from_hash(payload)) if payload 127 + }, 128 + error: ->(err) { @events.emit(:ws_error, wrap_error(err)) }, 129 + complete: -> {} 130 + ) 131 + 132 + @subscriptions << @ws.subscribe( 133 + "subscription PlaybackStatus { playbackStatus { status } }", nil, 134 + next: ->(result) { 135 + status = result[:data]&.dig(:playback_status, :status) 136 + @events.emit(:status_changed, status) unless status.nil? 137 + }, 138 + error: ->(err) { @events.emit(:ws_error, wrap_error(err)) }, 139 + complete: -> {} 140 + ) 141 + 142 + @subscriptions << @ws.subscribe( 143 + <<~GQL, nil, 144 + subscription PlaylistChanged { 145 + playlistChanged { 146 + amount index maxPlaylistSize firstIndex lastInsertPos seed lastShuffledStart 147 + tracks { id title artist album path length albumArt } 148 + } 149 + } 150 + GQL 151 + next: ->(result) { 152 + payload = result[:data]&.dig(:playlist_changed) 153 + if payload 154 + tracks = Array(payload[:tracks]).map { |t| Track.from_hash(t) } 155 + playlist = Rockbox::Playlist.new( 156 + amount: payload[:amount], 157 + index: payload[:index], 158 + max_playlist_size: payload[:max_playlist_size], 159 + first_index: payload[:first_index], 160 + last_insert_pos: payload[:last_insert_pos], 161 + seed: payload[:seed], 162 + last_shuffled_start: payload[:last_shuffled_start], 163 + tracks: tracks 164 + ) 165 + @events.emit(:playlist_changed, playlist) 166 + end 167 + }, 168 + error: ->(err) { @events.emit(:ws_error, wrap_error(err)) }, 169 + complete: -> {} 170 + ) 171 + 172 + @events.emit(:ws_open) 173 + self 174 + end 175 + 176 + # Tear down subscriptions and close the WebSocket. 177 + def disconnect 178 + @subscriptions.each { |unsub| unsub.call rescue nil } 179 + @subscriptions.clear 180 + @ws.dispose 181 + @events.emit(:ws_close) 182 + self 183 + end 184 + 185 + # --------------------------------------------------------------------------- 186 + # Plugin system 187 + # --------------------------------------------------------------------------- 188 + 189 + # @example 190 + # client.use(MyScrobbler.new(api_key: "...")) 191 + def use(plugin) 192 + ctx = PluginContext.new( 193 + query: ->(gql, variables = nil) { @http.execute(gql, variables) }, 194 + events: @events 195 + ) 196 + @plugins.register(plugin, ctx) 197 + self 198 + end 199 + 200 + def unuse(name) 201 + @plugins.unregister(name) 202 + self 203 + end 204 + 205 + def installed_plugins 206 + @plugins.list 207 + end 208 + 209 + # --------------------------------------------------------------------------- 210 + # Raw escape hatch — for one-off GraphQL operations 211 + # --------------------------------------------------------------------------- 212 + 213 + # @param query [String] 214 + # @param variables [Hash, nil] 215 + # @return [Hash] the snake-cased data object 216 + def query(query, variables = nil) 217 + @http.execute(query, variables) 218 + end 219 + 220 + private 221 + 222 + def wrap_error(err) 223 + err.is_a?(Exception) ? err : NetworkError.new(err.to_s) 224 + end 225 + end 226 + end
+38
sdk/ruby/lib/rockbox/configuration.rb
··· 1 + # frozen_string_literal: true 2 + 3 + module Rockbox 4 + # Mutable configuration holder used by the builder block API. 5 + # 6 + # @example 7 + # client = Rockbox::Client.build do |c| 8 + # c.host = "192.168.1.42" 9 + # c.port = 6062 10 + # end 11 + class Configuration 12 + DEFAULT_HOST = "localhost" 13 + DEFAULT_PORT = 6062 14 + 15 + attr_accessor :host, :port, :http_url, :ws_url, :open_timeout, :read_timeout 16 + 17 + def initialize(host: nil, port: nil, http_url: nil, ws_url: nil, 18 + open_timeout: nil, read_timeout: nil) 19 + @host = host 20 + @port = port 21 + @http_url = http_url 22 + @ws_url = ws_url 23 + @open_timeout = open_timeout 24 + @read_timeout = read_timeout 25 + end 26 + 27 + def resolved_host; @host || DEFAULT_HOST end 28 + def resolved_port; @port || DEFAULT_PORT end 29 + 30 + def resolved_http_url 31 + @http_url || "http://#{resolved_host}:#{resolved_port}/graphql" 32 + end 33 + 34 + def resolved_ws_url 35 + @ws_url || "ws://#{resolved_host}:#{resolved_port}/graphql" 36 + end 37 + end 38 + end
+33
sdk/ruby/lib/rockbox/errors.rb
··· 1 + # frozen_string_literal: true 2 + 3 + module Rockbox 4 + # Base class for every error raised by the SDK. 5 + class Error < StandardError 6 + attr_reader :cause 7 + 8 + def initialize(message, cause: nil) 9 + super(message) 10 + @cause = cause 11 + end 12 + end 13 + 14 + # Raised when the HTTP/WebSocket transport cannot reach rockboxd. 15 + class NetworkError < Error; end 16 + 17 + # Raised when rockboxd returns a GraphQL `errors` payload. 18 + # 19 + # @example 20 + # begin 21 + # client.playback.play 22 + # rescue Rockbox::GraphQLError => e 23 + # puts e.errors.first[:message] 24 + # end 25 + class GraphQLError < Error 26 + attr_reader :errors 27 + 28 + def initialize(errors) 29 + @errors = Array(errors) 30 + super(@errors.map { |e| e[:message] || e["message"] }.compact.join("; ")) 31 + end 32 + end 33 + end
+78
sdk/ruby/lib/rockbox/events.rb
··· 1 + # frozen_string_literal: true 2 + 3 + module Rockbox 4 + # Lightweight typed event emitter. The set of valid events is documented 5 + # below; emitting an unknown event still works (no enforcement) so plugins 6 + # can publish their own. 7 + # 8 + # Built-in events: 9 + # 10 + # | Event | Payload | 11 + # |--------------------|--------------------------------------| 12 + # | :track_changed | Rockbox::Track | 13 + # | :status_changed | Integer (Rockbox::PlaybackStatus) | 14 + # | :playlist_changed | Rockbox::Playlist | 15 + # | :ws_open | nil | 16 + # | :ws_close | nil | 17 + # | :ws_error | Exception/StandardError | 18 + class EventEmitter 19 + def initialize 20 + @listeners = Hash.new { |h, k| h[k] = [] } 21 + @lock = Mutex.new 22 + end 23 + 24 + # @example 25 + # client.on(:track_changed) { |track| puts track.title } 26 + def on(event, &block) 27 + raise ArgumentError, "block required" unless block 28 + @lock.synchronize { @listeners[event.to_sym] << block } 29 + self 30 + end 31 + 32 + # @example 33 + # client.once(:ws_open) { puts "connected!" } 34 + def once(event, &block) 35 + raise ArgumentError, "block required" unless block 36 + wrapper = nil 37 + wrapper = lambda do |*args| 38 + off(event, wrapper) 39 + block.call(*args) 40 + end 41 + on(event, &wrapper) 42 + end 43 + 44 + def off(event, listener = nil, &block) 45 + target = block || listener 46 + @lock.synchronize do 47 + if target.nil? 48 + @listeners.delete(event.to_sym) 49 + else 50 + @listeners[event.to_sym].delete(target) 51 + end 52 + end 53 + self 54 + end 55 + 56 + def emit(event, payload = nil) 57 + listeners = @lock.synchronize { @listeners[event.to_sym].dup } 58 + listeners.each do |listener| 59 + if listener.arity.zero? || payload.nil? 60 + listener.call 61 + else 62 + listener.call(payload) 63 + end 64 + end 65 + end 66 + 67 + def remove_all_listeners(event = nil) 68 + @lock.synchronize do 69 + if event 70 + @listeners.delete(event.to_sym) 71 + else 72 + @listeners.clear 73 + end 74 + end 75 + self 76 + end 77 + end 78 + end
+60
sdk/ruby/lib/rockbox/plugin.rb
··· 1 + # frozen_string_literal: true 2 + 3 + module Rockbox 4 + # Context handed to a plugin's #install method. Plugins can issue raw 5 + # GraphQL queries and subscribe to the same event stream the SDK uses. 6 + PluginContext = Struct.new(:query, :events, keyword_init: true) do 7 + def query!(*args, **kwargs) 8 + query.call(*args, **kwargs) 9 + end 10 + end 11 + 12 + # Plugin contract — duck-typed. A plugin must respond to: 13 + # #name => String (unique) 14 + # #version => String 15 + # #install(ctx) => any (called when registered) 16 + # 17 + # Optional: 18 + # #description => String 19 + # #uninstall => any (called on unregister) 20 + # 21 + # Inherit from Plugin or just implement the methods directly. 22 + class Plugin 23 + def name; raise NotImplementedError; end 24 + def version; "0.0.0"; end 25 + def description; nil; end 26 + def install(_context); end 27 + def uninstall; end 28 + end 29 + 30 + class PluginRegistry 31 + def initialize 32 + @plugins = {} 33 + @lock = Mutex.new 34 + end 35 + 36 + def register(plugin, context) 37 + name = plugin.name.to_s 38 + @lock.synchronize do 39 + raise ArgumentError, "Plugin #{name.inspect} is already installed" if @plugins.key?(name) 40 + end 41 + plugin.install(context) 42 + @lock.synchronize { @plugins[name] = plugin } 43 + plugin 44 + end 45 + 46 + def unregister(name) 47 + plugin = @lock.synchronize { @plugins.delete(name.to_s) } 48 + plugin&.uninstall if plugin.respond_to?(:uninstall) 49 + plugin 50 + end 51 + 52 + def installed?(name) 53 + @lock.synchronize { @plugins.key?(name.to_s) } 54 + end 55 + 56 + def list 57 + @lock.synchronize { @plugins.values.dup } 58 + end 59 + end 60 + end
+202
sdk/ruby/lib/rockbox/transport.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require "json" 4 + require "net/http" 5 + require "uri" 6 + require "securerandom" 7 + 8 + require_relative "errors" 9 + require_relative "case_conversion" 10 + 11 + module Rockbox 12 + # --------------------------------------------------------------------------- 13 + # HTTP transport — POSTs GraphQL queries to rockboxd. 14 + # 15 + # Every outgoing variables hash is camelCased and every incoming `data` 16 + # payload is deep-snakeized so callers see idiomatic Ruby keys. 17 + # --------------------------------------------------------------------------- 18 + class HttpTransport 19 + DEFAULT_OPEN_TIMEOUT = 5 20 + DEFAULT_READ_TIMEOUT = 30 21 + 22 + def initialize(url, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT) 23 + @uri = URI.parse(url) 24 + @open_timeout = open_timeout 25 + @read_timeout = read_timeout 26 + end 27 + 28 + # Execute a GraphQL operation. Returns the snake-cased `data` Hash. 29 + def execute(query, variables = nil) 30 + body = { query: query } 31 + body[:variables] = CaseConversion.deep_camelize(variables) if variables && !variables.empty? 32 + 33 + response = perform_request(body) 34 + parse_response(response) 35 + end 36 + 37 + private 38 + 39 + def perform_request(body) 40 + http = Net::HTTP.new(@uri.host, @uri.port) 41 + http.use_ssl = (@uri.scheme == "https") 42 + http.open_timeout = @open_timeout 43 + http.read_timeout = @read_timeout 44 + 45 + request = Net::HTTP::Post.new(@uri.request_uri) 46 + request["Content-Type"] = "application/json" 47 + request["Accept"] = "application/json" 48 + request.body = JSON.generate(body) 49 + 50 + http.request(request) 51 + rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, Net::OpenTimeout, Net::ReadTimeout => e 52 + raise NetworkError.new("Failed to reach Rockbox at #{@uri}: #{e.message}", cause: e) 53 + end 54 + 55 + def parse_response(response) 56 + unless response.is_a?(Net::HTTPSuccess) 57 + raise NetworkError, "HTTP #{response.code} #{response.message}" 58 + end 59 + 60 + payload = JSON.parse(response.body) 61 + if (errors = payload["errors"]) && !errors.empty? 62 + raise GraphQLError, errors.map { |e| CaseConversion.deep_snakeize(e) } 63 + end 64 + 65 + CaseConversion.deep_snakeize(payload["data"]) || {} 66 + rescue JSON::ParserError => e 67 + raise NetworkError.new("Invalid JSON response: #{e.message}", cause: e) 68 + end 69 + end 70 + 71 + # --------------------------------------------------------------------------- 72 + # WebSocket transport — speaks the `graphql-transport-ws` protocol. 73 + # 74 + # Each call to {#subscribe} returns a "stop" lambda that cancels the 75 + # subscription. Reconnection is intentionally simple: the transport is 76 + # disposable, so callers should rebuild the client on terminal errors. 77 + # --------------------------------------------------------------------------- 78 + class WsTransport 79 + GRAPHQL_TRANSPORT_WS = "graphql-transport-ws" 80 + 81 + def initialize(url) 82 + @url = url 83 + @client = nil 84 + @lock = Mutex.new 85 + @sinks = {} # subscription id => sink hash 86 + @ack = false 87 + @ack_signal = ConditionVariable.new 88 + end 89 + 90 + # @param query [String] 91 + # @param variables [Hash, nil] 92 + # @param sink [Hash{Symbol => Proc}] keys: :next, :error, :complete 93 + # @return [Proc] call to unsubscribe 94 + def subscribe(query, variables, sink) 95 + ensure_connected 96 + 97 + sub_id = SecureRandom.uuid 98 + @lock.synchronize { @sinks[sub_id] = sink } 99 + 100 + send_message( 101 + id: sub_id, 102 + type: "subscribe", 103 + payload: { 104 + query: query, 105 + variables: variables ? CaseConversion.deep_camelize(variables) : {} 106 + } 107 + ) 108 + 109 + lambda do 110 + send_message(id: sub_id, type: "complete") rescue nil 111 + @lock.synchronize { @sinks.delete(sub_id) } 112 + end 113 + end 114 + 115 + def dispose 116 + @lock.synchronize do 117 + @sinks.clear 118 + if @client 119 + begin 120 + @client.close 121 + rescue StandardError 122 + # ignored 123 + end 124 + @client = nil 125 + @ack = false 126 + end 127 + end 128 + end 129 + 130 + private 131 + 132 + def ensure_connected 133 + @lock.synchronize do 134 + return if @client && @ack 135 + 136 + require "websocket-client-simple" unless defined?(WebSocket::Client::Simple) 137 + 138 + transport = self 139 + @ack = false 140 + 141 + @client = WebSocket::Client::Simple.connect(@url, headers: { "Sec-WebSocket-Protocol" => GRAPHQL_TRANSPORT_WS }) 142 + 143 + @client.on(:open) { transport.send(:on_open) } 144 + @client.on(:message) { |msg| transport.send(:on_message, msg) } 145 + @client.on(:error) { |err| transport.send(:on_error, err) } 146 + @client.on(:close) { transport.send(:on_close) } 147 + 148 + # Wait for connection_ack before returning. 149 + deadline = Time.now + 5.0 150 + until @ack 151 + remaining = deadline - Time.now 152 + raise NetworkError, "Timed out waiting for graphql-transport-ws connection_ack" if remaining <= 0 153 + @ack_signal.wait(@lock, remaining) 154 + end 155 + end 156 + end 157 + 158 + def on_open 159 + send_message(type: "connection_init", payload: {}) 160 + end 161 + 162 + def on_message(msg) 163 + data = msg.respond_to?(:data) ? msg.data : msg.to_s 164 + payload = JSON.parse(data) 165 + 166 + case payload["type"] 167 + when "connection_ack" 168 + @lock.synchronize do 169 + @ack = true 170 + @ack_signal.broadcast 171 + end 172 + when "next" 173 + sink = @lock.synchronize { @sinks[payload["id"]] } 174 + next_payload = payload["payload"] || {} 175 + data_hash = CaseConversion.deep_snakeize(next_payload["data"]) 176 + sink&.dig(:next)&.call(data: data_hash) 177 + when "error" 178 + sink = @lock.synchronize { @sinks[payload["id"]] } 179 + sink&.dig(:error)&.call(payload["payload"]) 180 + when "complete" 181 + sink = @lock.synchronize { @sinks.delete(payload["id"]) } 182 + sink&.dig(:complete)&.call 183 + end 184 + rescue JSON::ParserError 185 + # Drop malformed frames silently; rockboxd never sends them. 186 + end 187 + 188 + def on_error(err) 189 + sinks = @lock.synchronize { @sinks.values.dup } 190 + sinks.each { |s| s[:error]&.call(err) } 191 + end 192 + 193 + def on_close 194 + @lock.synchronize { @ack = false } 195 + end 196 + 197 + def send_message(message) 198 + raise NetworkError, "WebSocket is not connected" unless @client 199 + @client.send(JSON.generate(message)) 200 + end 201 + end 202 + end
+152
sdk/ruby/lib/rockbox/types.rb
··· 1 + # frozen_string_literal: true 2 + 3 + module Rockbox 4 + # Numeric playback states reported by the firmware. 5 + module PlaybackStatus 6 + STOPPED = 0 7 + PLAYING = 1 8 + PAUSED = 3 9 + 10 + def self.name(value) 11 + { 0 => :stopped, 1 => :playing, 3 => :paused }[value] || :unknown 12 + end 13 + end 14 + 15 + module RepeatMode 16 + OFF = 0 17 + ALL = 1 18 + ONE = 2 19 + SHUFFLE = 3 20 + AB_REPEAT = 4 21 + end 22 + 23 + module ChannelConfig 24 + STEREO = 0 25 + STEREO_NARROW = 1 26 + MONO = 2 27 + LEFT_MIX = 3 28 + RIGHT_MIX = 4 29 + KARAOKE = 5 30 + end 31 + 32 + module ReplaygainType 33 + TRACK = 0 34 + ALBUM = 1 35 + SHUFFLE = 2 36 + end 37 + 38 + # Where to insert tracks in the queue (Kodi/Mopidy convention). 39 + module InsertPosition 40 + NEXT = 0 41 + AFTER_CURRENT = 1 42 + LAST = 2 43 + FIRST = 3 44 + end 45 + 46 + # --------------------------------------------------------------------------- 47 + # Value objects 48 + # --------------------------------------------------------------------------- 49 + # 50 + # Each type is a Struct that ignores unknown keys at construction so the 51 + # firmware can add fields without breaking older SDK builds. 52 + # --------------------------------------------------------------------------- 53 + 54 + module Type 55 + # Build a Struct that tolerates unknown / missing fields when coming 56 + # from a Hash. Struct subclasses raise on unknown keys with `keyword_init`, 57 + # so {.from_hash} filters the input. 58 + def self.with(*members) 59 + klass = Struct.new(*members, keyword_init: true) 60 + klass.define_singleton_method(:from_hash) do |hash| 61 + return nil if hash.nil? 62 + attrs = {} 63 + members.each { |m| attrs[m] = hash[m] } 64 + new(**attrs) 65 + end 66 + klass.define_singleton_method(:known_members) { members.dup } 67 + klass 68 + end 69 + end 70 + 71 + Track = Type.with( 72 + :id, :title, :artist, :album, :genre, :disc, :track_string, :year_string, 73 + :composer, :comment, :album_artist, :grouping, 74 + :discnum, :tracknum, :layer, :year, :bitrate, :frequency, 75 + :filesize, :length, :elapsed, :path, 76 + :album_id, :artist_id, :genre_id, :album_art 77 + ) 78 + 79 + Album = Type.with( 80 + :id, :title, :artist, :year, :year_string, :album_art, :md5, 81 + :artist_id, :copyright_message, :tracks 82 + ) 83 + 84 + Artist = Type.with( 85 + :id, :name, :bio, :image, :tracks, :albums 86 + ) 87 + 88 + SearchResults = Type.with( 89 + :artists, :albums, :tracks, :liked_tracks, :liked_albums 90 + ) 91 + 92 + Playlist = Type.with( 93 + :amount, :index, :max_playlist_size, :first_index, 94 + :last_insert_pos, :seed, :last_shuffled_start, :tracks 95 + ) 96 + 97 + SavedPlaylist = Type.with( 98 + :id, :name, :description, :image, :folder_id, 99 + :track_count, :created_at, :updated_at 100 + ) 101 + 102 + SavedPlaylistFolder = Type.with(:id, :name, :created_at, :updated_at) 103 + 104 + SmartPlaylist = Type.with( 105 + :id, :name, :description, :image, :folder_id, :is_system, 106 + :rules, :created_at, :updated_at 107 + ) 108 + 109 + TrackStats = Type.with( 110 + :track_id, :play_count, :skip_count, :last_played, :last_skipped, :updated_at 111 + ) 112 + 113 + BluetoothDevice = Type.with( 114 + :address, :name, :paired, :trusted, :connected, :rssi 115 + ) 116 + 117 + VolumeInfo = Type.with(:volume, :min, :max) 118 + 119 + Device = Type.with( 120 + :id, :name, :host, :ip, :port, :service, :app, :is_connected, 121 + :base_url, :is_cast_device, :is_source_device, :is_current_device 122 + ) 123 + 124 + Entry = Type.with(:name, :attr, :time_write, :customaction, :display_name) 125 + 126 + # File-attribute bit set on directory entries. 127 + ENTRY_DIR_BIT = 0x10 128 + 129 + def self.directory?(entry) 130 + (entry.attr.to_i & ENTRY_DIR_BIT) != 0 131 + end 132 + 133 + SystemStatus = Type.with( 134 + :resume_index, :resume_crc32, :resume_elapsed, :resume_offset, 135 + :runtime, :topruntime, :dircache_size, 136 + :last_screen, :viewer_icon_count, :last_volume_change 137 + ) 138 + 139 + EqBandSetting = Type.with(:cutoff, :q, :gain) 140 + ReplaygainSettings = Type.with(:noclip, :type, :preamp) 141 + CompressorSettings = Type.with(:threshold, :makeup_gain, :ratio, :knee, :release_time, :attack_time) 142 + 143 + UserSettings = Type.with( 144 + :music_dir, :volume, :balance, :bass, :treble, :channel_config, :stereo_width, 145 + :eq_enabled, :eq_precut, :eq_band_settings, :replaygain_settings, :compressor_settings, 146 + :crossfade_enabled, :crossfade_fade_in_delay, :crossfade_fade_in_duration, 147 + :crossfade_fade_out_delay, :crossfade_fade_out_duration, :crossfade_fade_out_mixmode, 148 + :crossfeed_enabled, :crossfeed_direct_gain, :crossfeed_cross_gain, 149 + :crossfeed_hf_attenuation, :crossfeed_hf_cutoff, 150 + :repeat_mode, :single_mode, :party_mode, :shuffle, :player_name 151 + ) 152 + end
+5
sdk/ruby/lib/rockbox/version.rb
··· 1 + # frozen_string_literal: true 2 + 3 + module Rockbox 4 + VERSION = "0.1.0" 5 + end
+36
sdk/ruby/rockbox.gemspec
··· 1 + # frozen_string_literal: true 2 + 3 + require_relative "lib/rockbox/version" 4 + 5 + Gem::Specification.new do |spec| 6 + spec.name = "rockbox" 7 + spec.version = Rockbox::VERSION 8 + spec.authors = ["Tsiry Sandratraina"] 9 + spec.email = ["tsiry.sndr@rocksky.app"] 10 + 11 + spec.summary = "Idiomatic Ruby SDK for Rockbox" 12 + spec.description = "Ruby SDK for Rockbox — a builder-friendly, block-friendly GraphQL client " \ 13 + "with real-time event subscriptions and a plugin system." 14 + spec.homepage = "https://github.com/tsirysndr/rockbox-zig" 15 + spec.license = "MIT" 16 + 17 + spec.required_ruby_version = ">= 3.0" 18 + 19 + spec.metadata["homepage_uri"] = spec.homepage 20 + spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/master/sdk/ruby" 21 + spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues" 22 + spec.metadata["documentation_uri"] = "https://www.rockbox.org" 23 + 24 + spec.files = Dir[ 25 + "lib/**/*.rb", 26 + "README.md", 27 + "rockbox.gemspec" 28 + ] 29 + spec.require_paths = ["lib"] 30 + 31 + spec.add_dependency "websocket-client-simple", "~> 0.6" 32 + 33 + spec.add_development_dependency "bundler", "~> 2.0" 34 + spec.add_development_dependency "minitest", "~> 5.0" 35 + spec.add_development_dependency "rake", "~> 13.0" 36 + end
+21
sdk/typescript/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Tsiry Sandratraina <tsiry.sndr@rocksky.app> 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+6
sdk/typescript/README.md
··· 957 957 const dirs = entries.filter(isDirectory); 958 958 const files = entries.filter((e) => !isDirectory(e)); 959 959 ``` 960 + 961 + --- 962 + 963 + ## License 964 + 965 + MIT License. See [LICENSE](./LICENSE) for details.