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.
- checksums.yaml +7 -0
- data/README.md +473 -0
- data/lib/rockbox/api/bluetooth.rb +52 -0
- data/lib/rockbox/api/browse.rb +31 -0
- data/lib/rockbox/api/devices.rb +38 -0
- data/lib/rockbox/api/library.rb +167 -0
- data/lib/rockbox/api/playback.rb +148 -0
- data/lib/rockbox/api/playlist.rb +127 -0
- data/lib/rockbox/api/saved_playlists.rb +163 -0
- data/lib/rockbox/api/settings.rb +118 -0
- data/lib/rockbox/api/smart_playlists.rb +115 -0
- data/lib/rockbox/api/sound.rb +31 -0
- data/lib/rockbox/api/system.rb +32 -0
- data/lib/rockbox/case_conversion.rb +43 -0
- data/lib/rockbox/client.rb +226 -0
- data/lib/rockbox/configuration.rb +38 -0
- data/lib/rockbox/errors.rb +33 -0
- data/lib/rockbox/events.rb +78 -0
- data/lib/rockbox/plugin.rb +60 -0
- data/lib/rockbox/transport.rb +202 -0
- data/lib/rockbox/types.rb +152 -0
- data/lib/rockbox/version.rb +5 -0
- data/lib/rockbox.rb +19 -0
- data/rockbox.gemspec +36 -0
- metadata +124 -0
|
@@ -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
|