rockbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
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
@@ -0,0 +1,115 @@
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
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,226 @@
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
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,33 @@
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