dacpclient 0.2.6 → 0.2.9

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.
@@ -1,18 +1,25 @@
1
- require 'faraday'
2
1
  require 'digest'
3
- require 'net/http'
4
2
  require 'uri'
5
- require 'cgi'
6
3
  require 'plist'
4
+ require 'dmapparser'
5
+ require 'faraday'
6
+ require 'dacpclient/faraday/flatter_params_encoder'
7
7
  require 'dacpclient/pairingserver'
8
- require 'dacpclient/dmapparser'
9
- require 'dacpclient/dmapbuilder'
8
+ require 'dacpclient/browser'
10
9
  require 'dacpclient/version'
10
+ require 'dacpclient/model'
11
+ require 'dacpclient/models/status'
12
+ require 'dacpclient/models/pair_info'
13
+ require 'dacpclient/models/playlist'
14
+ require 'dacpclient/models/playlists'
15
+ require 'dacpclient/models/play_queue_item'
16
+ require 'dacpclient/models/play_queue'
11
17
 
12
18
  module DACPClient
13
19
  # The Client class handles communication with the server
14
20
  class Client
15
- attr_accessor :guid, :hsgid
21
+ attr_accessor :hsgid
22
+ attr_writer :guid
16
23
  attr_reader :name, :host, :port, :session_id
17
24
 
18
25
  HOME_SHARING_HOST = 'https://homesharing.itunes.apple.com'
@@ -26,25 +33,23 @@ module DACPClient
26
33
  }.freeze
27
34
 
28
35
  def initialize(name, host = 'localhost', port = 3689)
29
- @client = Net::HTTP.new(host, port)
30
36
  @name = name
31
37
  @host = host
32
38
  @port = port
33
39
 
34
40
  @session_id = nil
35
41
  @hsgid = nil
36
- @mediarevision = 1
37
- @uri = URI::HTTP.build(host: @host, port: @port)
38
- @client = Faraday.new(url: @uri.to_s)
42
+ @media_revision = 1
43
+ setup_connection
39
44
  end
40
45
 
41
- [:play, :playpause, :stop, :pause,
46
+ [:play, :playpause, :stop, :pause,
42
47
  :nextitem, :previtem, :getspeakers].each do |action_name|
43
- define_method action_name do
48
+ define_method action_name do
44
49
  do_action action_name
45
50
  end
46
51
  end
47
-
52
+
48
53
  alias_method :previous, :previtem
49
54
  alias_method :prev, :previtem
50
55
  alias_method :next, :nextitem
@@ -70,22 +75,22 @@ module DACPClient
70
75
  end
71
76
 
72
77
  def pair(pin)
73
- pairingserver = PairingServer.new(self, '0.0.0.0', 1024)
78
+ pairingserver = PairingServer.new(name, guid)
74
79
  pairingserver.pin = pin
75
80
  pairingserver.start
76
81
  end
77
82
 
78
83
  def serverinfo
79
- do_action('server-info', {}, true)
84
+ do_action('server-info', clean_url: true)
80
85
  end
81
86
 
82
87
  def login
83
88
  response = nil
84
89
  if @hsgid.nil?
85
90
  pairing_guid = '0x' + guid
86
- response = do_action(:login, 'pairing-guid' => pairing_guid)
91
+ response = do_action(:login, :'pairing-guid' => pairing_guid)
87
92
  else
88
- response = do_action(:login, 'hasFP' => '1')
93
+ response = do_action(:login, hasFP: 1)
89
94
  end
90
95
  @session_id = response[:mlid]
91
96
  response
@@ -93,16 +98,22 @@ module DACPClient
93
98
 
94
99
  def pair_and_login(pin = nil)
95
100
  login
96
- rescue DACPForbiddenError => e
101
+ rescue DACPForbiddenError, Faraday::ConnectionFailed => e
97
102
  pin = 4.times.map { Random.rand(10) } if pin.nil?
98
- warn "#{e.result.status} error: Cannot login, starting pairing process"
103
+ if e.instance_of? DACPForbiddenError
104
+ message = e.result.status
105
+ else
106
+ message = e
107
+ end
108
+ warn "#{message} error: Cannot login, starting pairing process"
99
109
  warn "Pincode: #{pin}"
100
- pair(pin)
110
+ @host = pair(pin).host
111
+ setup_connection
101
112
  retry
102
113
  end
103
114
 
104
115
  def content_codes
105
- do_action('content-codes', {}, true)
116
+ do_action('content-codes', clean_url: true)
106
117
  end
107
118
 
108
119
  def track_length
@@ -122,9 +133,10 @@ module DACPClient
122
133
  alias_method :position=, :seek
123
134
 
124
135
  def status(wait = false)
125
- revision = wait ? @mediarevision : 1
126
- result = do_action(:playstatusupdate, 'revision-number' => revision)
127
- @mediarevision = result[:cmsr]
136
+ revision = wait ? @media_revision : 1
137
+ result = do_action(:playstatusupdate, :'revision-number' => revision,
138
+ model: Status)
139
+ @media_revision = result.media_revision
128
140
  result
129
141
  rescue Faraday::Error::TimeoutError => e
130
142
  if wait
@@ -140,7 +152,7 @@ module DACPClient
140
152
  end
141
153
 
142
154
  def volume=(volume)
143
- do_action(:setproperty, 'dmcp.volume' => volume)
155
+ do_action(:setproperty, :'dmcp.volume' => volume)
144
156
  end
145
157
 
146
158
  def repeat
@@ -162,12 +174,12 @@ module DACPClient
162
174
  end
163
175
 
164
176
  def ctrl_int
165
- do_action('ctrl-int', {}, true)
177
+ do_action('ctrl-int', clean_url: true)
166
178
  end
167
179
 
168
180
  def logout
169
181
  do_action(:logout)
170
- @mediarevision = 1
182
+ @media_revision = 1
171
183
  @session_id = nil
172
184
  end
173
185
 
@@ -181,35 +193,37 @@ module DACPClient
181
193
  end
182
194
 
183
195
  def list_queue
184
- do_action('playqueue-contents')
196
+ do_action('playqueue-contents', model: PlayQueue)
185
197
  end
186
198
 
187
199
  def databases
188
- do_action('databases', {}, true)
200
+ do_action('databases', clean_url: true)
189
201
  end
190
202
 
191
- def playlists(db)
192
- do_action("databases/#{db}/containers", {}, true)
203
+ def playlists(db = default_db)
204
+ do_action("databases/#{db.miid}/containers", clean_url: true,
205
+ model: Playlists).items
193
206
  end
194
207
 
195
208
  def default_db
196
- databases[:mlcl].to_a.find { |item| item.mdbk == 1 }
209
+ databases.mlcl.to_a.find { |item| item.mdbk == 1 }
197
210
  end
198
211
 
199
- def default_playlist(db)
200
- @client.playlists(72).mlcl.to_a.find { |item| item.abpl }
212
+ def default_playlist(db = default_db)
213
+ playlists(db).find { |item| item.base_playlist? }
201
214
  end
202
215
 
203
216
  def artwork(database, id, width = 320, height = 320)
204
217
  url = "databases/#{database}/items/#{id}/extra_data/artwork"
205
- do_action(url, { mw: width, mh: height }, true)
218
+ do_action(url, { mw: width, mh: height }, clean_url: true)
206
219
  end
207
220
 
208
221
  def now_playing_artwork(width = 320, height = 320)
209
222
  do_action(:nowplayingartwork, mw: width, mh: height)
210
223
  end
211
224
 
212
- def search(db, container, search, type = nil)
225
+ def search(search, type = nil, db = default_db,
226
+ container = default_playlist(default_db))
213
227
  search = URI.escape(search)
214
228
  types = {
215
229
  title: 'dmap.itemname',
@@ -225,40 +239,54 @@ module DACPClient
225
239
  end
226
240
 
227
241
  q = queries.join(',')
228
- meta = %w(dmap.itemname dmap.itemid daap.songartist daap.songalbumartist
242
+ q = '(' + q + ')' if queries.length > 1
243
+ meta = %w(dmap.itemname dmap.itemid com.apple.itunes.has-chapter-data
229
244
  daap.songalbum com.apple.itunes.cloud-id dmap.containeritemid
230
245
  com.apple.itunes.has-video com.apple.itunes.itms-songid
231
246
  com.apple.itunes.extended-media-kind dmap.downloadstatus
232
- daap.songdisabled).join(',')
233
-
234
- url = "databases/#{db}/containers/#{container}/items"
235
- do_action(url, { type: 'music', sort: 'album', query: q, meta: meta },
236
- true)
247
+ daap.songdisabled daap.songhasbeenplayed daap.songbookmark
248
+ com.apple.itunes.is-hd-video daap.songlongcontentdescription
249
+ daap.songtime daap.songuserplaycount daap.songartist
250
+ com.apple.itunes.content-rating daap.songdatereleased
251
+ com.apple.itunes.movie-info-xml daap.songalbumartist
252
+ com.apple.itunes.extended-media-kind).join(',')
253
+ url = "databases/#{db.miid}/containers/#{container.miid}/items"
254
+ do_action(url, { query: q, type: 'music', sort: 'album', meta: meta,
255
+ :'include-sort-headers' => 1 }, clean_url: true)
237
256
  end
238
257
 
239
258
  private
240
259
 
241
- def do_action(action, params = {}, cleanurl = false)
260
+ def setup_connection
261
+ @uri = URI::HTTP.build(host: @host, port: @port)
262
+ Faraday::Utils.default_params_encoder = Faraday::FlatterParamsEncoder
263
+ @client = Faraday.new(@uri.to_s)
264
+ end
265
+
266
+ def do_action(action, clean_url: false, model: nil, **params)
242
267
  action = '/' + action.to_s
243
268
  unless @session_id.nil?
244
- params['session-id'] = @session_id
245
- action = '/ctrl-int/1' + action unless cleanurl
269
+ params['session-id'] = @session_id.to_s
270
+ action = '/ctrl-int/1' + action unless clean_url
246
271
  end
247
272
  params['hsgid'] = @hsgid unless @hsgid.nil?
273
+
248
274
  result = @client.get do |request|
275
+ request.options.params_encoder = Faraday::FlatterParamsEncoder
249
276
  request.url action
250
277
  request.params = params
251
278
  request.headers.merge!(DEFAULT_HEADERS)
252
279
  end
253
280
 
254
- parse_result result
281
+ parse_result result, model
255
282
  end
256
-
257
- def parse_result(result)
283
+
284
+ def parse_result(result, model)
258
285
  if !result.success?
259
286
  fail DACPForbiddenError, result
260
287
  elsif result.headers['Content-Type'] == 'application/x-dmap-tagged'
261
- DMAPParser.parse(result.body)
288
+ res = DMAPParser::Parser.parse(result.body)
289
+ model ? model.new(res) : res
262
290
  else
263
291
  result.body
264
292
  end
@@ -0,0 +1,77 @@
1
+ # rubocop:disable all
2
+ require 'cgi'
3
+ module Faraday
4
+ module FlatterParamsEncoder
5
+ def self.escape(s)
6
+ s.to_s.gsub(/[^a-zA-Z0-9 .~_\-,:\*'\+()]/) do
7
+ '%' + $&.unpack('H2' * $&.bytesize).join('%').upcase
8
+ end.tr(' ', '+')
9
+ end
10
+
11
+ def self.unescape(s)
12
+ CGI.unescape(s.to_s)
13
+ end
14
+
15
+ def self.encode(params)
16
+ return nil if params.nil?
17
+
18
+ unless params.is_a?(Array)
19
+ unless params.respond_to?(:to_hash)
20
+ fail TypeError,
21
+ "Can't convert #{params.class} into Hash."
22
+ end
23
+ params = params.to_hash
24
+ params = params.map do |key, value|
25
+ key = key.to_s if key.kind_of?(Symbol)
26
+ [key, value]
27
+ end
28
+ # Useful default for OAuth and caching.
29
+ # Only to be used for non-Array inputs. Arrays should preserve order.
30
+ params.sort!
31
+ end
32
+
33
+ # The params have form [['key1', 'value1'], ['key2', 'value2']].
34
+ buffer = ''
35
+ params.each do |key, value|
36
+ encoded_key = escape(key)
37
+ value = value.to_s if value == true || value == false
38
+ if value.nil?
39
+ buffer << "#{encoded_key}&"
40
+ elsif value.kind_of?(Array)
41
+ value.each do |sub_value|
42
+ encoded_value = escape(sub_value)
43
+ buffer << "#{encoded_key}=#{encoded_value}&"
44
+ end
45
+ else
46
+ encoded_value = escape(value)
47
+ buffer << "#{encoded_key}=#{encoded_value}&"
48
+ end
49
+ end
50
+ buffer.chop
51
+ end
52
+
53
+ def self.decode(query)
54
+ empty_accumulator = {}
55
+ return nil if query.nil?
56
+ split_query = (query.split('&').map do |pair|
57
+ pair.split('=', 2) if pair && !pair.empty?
58
+ end).compact
59
+ split_query.reduce(empty_accumulator.dup) do |accu, pair|
60
+ pair[0] = unescape(pair[0])
61
+ pair[1] = true if pair[1].nil?
62
+ if pair[1].respond_to?(:to_str)
63
+ pair[1] = unescape(pair[1].to_str.gsub(/\+/, ' '))
64
+ end
65
+ if accu[pair[0]].kind_of?(Array)
66
+ accu[pair[0]] << pair[1]
67
+ elsif accu[pair[0]]
68
+ accu[pair[0]] = [accu[pair[0]], pair[1]]
69
+ else
70
+ accu[pair[0]] = pair[1]
71
+ end
72
+ accu
73
+ end
74
+ end
75
+ end
76
+ end
77
+ # rubocop:enable all
@@ -0,0 +1,117 @@
1
+ module DACPClient
2
+ class Model
3
+ class DMAPAttribute < Struct.new(:tag, :item_class, :value)
4
+ def initialize(tag, item_class = nil)
5
+ super tag, item_class, nil
6
+ end
7
+ end
8
+
9
+ def initialize(params = {})
10
+ if params.is_a? DMAPParser::TagContainer
11
+ deserialize(params)
12
+ elsif params
13
+ params.each do |attr, value|
14
+ public_send("#{attr}=", value)
15
+ end
16
+ end
17
+ end
18
+
19
+ def inspect
20
+ puts self.class.name
21
+ dmap_attributes.each do |key, value|
22
+ puts " #{key}: #{value.value}"
23
+ end
24
+ end
25
+
26
+ def to_s
27
+ "#<#{self.class.name} " + dmap_attributes.map do |key, value|
28
+ "#{key}: #{value.value}"
29
+ end.join(', ') + '>'
30
+ end
31
+
32
+ def to_dmap
33
+ attributes = dmap_attributes
34
+ DMAPParser::Builder.send dmap_tag do
35
+ attributes.values.each do |value|
36
+ send(value.tag, value.value)
37
+ end
38
+ end.to_dmap
39
+ end
40
+
41
+ def method_missing(method, *args, &block)
42
+ if method.to_s =~ /(.*)\=$/ &&
43
+ dmap_attributes.key?(Regexp.last_match[1].to_sym)
44
+ dmap_attributes[Regexp.last_match[1].to_sym].value = args.first
45
+ elsif method.to_s =~ /(.*)\?$/ &&
46
+ dmap_attributes.key?(Regexp.last_match[1].to_sym)
47
+ dmap_attributes[Regexp.last_match[1].to_sym].value
48
+ elsif dmap_attributes.key? method
49
+ dmap_attributes[method].value
50
+ else
51
+ super
52
+ end
53
+ end
54
+
55
+ class << self
56
+ def dmap_attribute(method, key)
57
+ @dmap_attributes ||= {}
58
+ @dmap_attributes[method] = key
59
+ end
60
+
61
+ def dmap_container(method, key, item_class)
62
+ @dmap_attributes ||= {}
63
+ @dmap_attributes[method] = [key, item_class]
64
+ end
65
+
66
+ def dmap_tag(tag = nil)
67
+ if tag
68
+ @dmap_tag = tag
69
+ else
70
+ @dmap_tag
71
+ end
72
+ end
73
+
74
+ def build_dmap(params = {})
75
+ new(params).to_dmap
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def deserialize(data)
82
+ warn 'Invalid tag' if data.type.tag.to_sym != dmap_tag
83
+ dmap_attributes.values.each do |value|
84
+ value.value = get_value(data, value) if data.respond_to? value.tag
85
+ end
86
+ self
87
+ end
88
+
89
+ def get_value(data, value)
90
+ item_class = value.item_class
91
+ if item_class
92
+ data.send(value.tag).to_a.map do |item|
93
+ item_class.new(item) if item_class.dmap_tag == item.type.tag.to_sym
94
+ end.compact
95
+ else
96
+ data.send(value.tag)
97
+ end
98
+ end
99
+
100
+ def dmap_attributes
101
+ @dmap_attributes ||= initialize_attributes
102
+ end
103
+
104
+ def initialize_attributes
105
+ class_attributes = self.class.instance_variable_get(:@dmap_attributes)
106
+ attributes = {}
107
+ class_attributes.map do |key, value|
108
+ attributes[key] = DMAPAttribute.new(*value)
109
+ end
110
+ attributes
111
+ end
112
+
113
+ def dmap_tag
114
+ self.class.instance_variable_get(:@dmap_tag)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,15 @@
1
+ module DACPClient
2
+ class PairInfo < Model
3
+ dmap_tag :cmpa
4
+
5
+ dmap_attribute :pairing_code, :cmpg
6
+ dmap_attribute :name, :cmnm
7
+ dmap_attribute :type, :cmty
8
+
9
+ # DMAPParser::Builder.cmpa do
10
+ # cmpg pair
11
+ # cmnm name
12
+ # cmty device_type
13
+ # end.to_dmap
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module DACPClient
2
+ class PlayQueue < Model
3
+ dmap_tag :ceQR
4
+ dmap_attribute :status, :mstt
5
+ dmap_attribute :container_count, :mtco
6
+ dmap_attribute :shuffle_mode, :apsm
7
+ dmap_attribute :repeat_mode, :aprm
8
+ # ceQu (unknown (1): unknown): 0
9
+ dmap_container :items, :mlcl, DACPClient::PlayQueueItem
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ module DACPClient
2
+ class PlayQueueItem < Model
3
+ dmap_tag :mlit
4
+ dmap_attribute :track_id, :ceQs
5
+ dmap_attribute :title, :ceQn
6
+ dmap_attribute :artist, :ceQr
7
+ dmap_attribute :album, :ceQa
8
+ dmap_attribute :genre, :ceQg
9
+ dmap_attribute :album_id, :asai
10
+
11
+ dmap_attribute :media_kind, :cmmk
12
+ dmap_attribute :song_time, :astm
13
+
14
+ # aeGs (com.apple.itunes.can-be-genius-seed: bool): true
15
+ # ceGS (com.apple.itunes.genius-selectable: bool): true
16
+ end
17
+ end
@@ -0,0 +1,9 @@
1
+ module DACPClient
2
+ class Playlist < Model
3
+ dmap_tag :mlit
4
+ dmap_attribute :item_id, :miid
5
+ dmap_attribute :name, :minm
6
+ dmap_attribute :base_playlist, :abpl
7
+ dmap_attribute :count, :mimc
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ module DACPClient
2
+ class Playlists < Model
3
+ dmap_tag :aply
4
+ dmap_attribute :status, :mstt
5
+ dmap_attribute :update_type, :myty
6
+ dmap_attribute :container_count, :mtco
7
+ dmap_attribute :returned_count, :mrco
8
+ dmap_container :items, :mlcl, DACPClient::Playlist
9
+ end
10
+ end
@@ -0,0 +1,44 @@
1
+ module DACPClient
2
+ class Status < Model
3
+ dmap_tag :cmst
4
+ dmap_attribute :media_revision, :cmsr
5
+ dmap_attribute :status_code, :mstt
6
+ dmap_attribute :play_status, :caps
7
+ dmap_attribute :shuffle_state, :cash
8
+ dmap_attribute :repeat_state, :carp
9
+ dmap_attribute :fullscreen, :cafs
10
+ dmap_attribute :visualizer, :cavs
11
+ dmap_attribute :volume_controllable, :cavc
12
+ dmap_attribute :album_shuffle, :caas
13
+ dmap_attribute :album_repeat, :caar
14
+ dmap_attribute :fullscreen_enabled, :cafe
15
+ dmap_attribute :visualizer_enabled, :cave
16
+ dmap_attribute :track_id, :canp
17
+ dmap_attribute :title, :cann
18
+ dmap_attribute :artist, :cana
19
+ dmap_attribute :album, :canl
20
+ dmap_attribute :album_id, :asai
21
+ dmap_attribute :media_kind, :cmmk
22
+ dmap_attribute :song_time, :astm
23
+ dmap_attribute :song_length, :cast
24
+ dmap_attribute :song_remaining_time, :cant
25
+
26
+ def song_position
27
+ return 0 unless song_length? && song_remaining_time?
28
+ song_length - song_remaining_time
29
+ end
30
+
31
+ def stopped?
32
+ play_status == 2
33
+ end
34
+
35
+ def playing?
36
+ play_status == 4
37
+ end
38
+
39
+ def paused?
40
+ !stopped? && !playing?
41
+ end
42
+ # casu (dacp.su: byte): 0
43
+ end
44
+ end
@@ -2,19 +2,22 @@ require 'socket'
2
2
  require 'dnssd'
3
3
  require 'digest'
4
4
  require 'gserver'
5
+ require 'dmapparser/builder'
5
6
  module DACPClient
6
7
  # The pairingserver handles pairing with iTunes
7
8
  class PairingServer < GServer
8
9
  attr_accessor :pin, :device_type
10
+ attr_reader :peer
9
11
 
10
12
  MDNS_TYPE = '_touch-remote._tcp'.freeze
11
13
 
12
- def initialize(client, host, port = 1024)
13
- @name = client.name
14
+ def initialize(name, guid, host = '0.0.0.0', port = 1024)
15
+ @name = name
14
16
  @port = port
15
17
  @host = host
16
- @pair = client.guid
18
+ @pair = guid
17
19
  @pin = [0, 0, 0, 0]
20
+ @peer = nil
18
21
  @device_type = 'iPod'
19
22
  super port, host
20
23
  end
@@ -23,24 +26,33 @@ module DACPClient
23
26
  @pairing_string = generate_pairing_string(@pair, @name, @device_type)
24
27
  @expected = PairingServer.generate_pin_challenge(@pair, @pin)
25
28
  @service = DNSSD.register!(@name, MDNS_TYPE, 'local', @port, text_record)
26
-
29
+ @pairing_string
30
+ PairInfo.new(DMAPParser::Parser.parse(@pairing_string))
27
31
  super
28
32
  join
29
33
 
30
34
  @service.stop
31
35
 
32
36
  sleep 0.5 # sleep so iTunes accepts our login
37
+ peer
33
38
  end
34
39
 
35
40
  def self.generate_pin_challenge(pair, pin)
36
41
  pin_string = pin.map { |i| "#{i}\x00" }.join
37
- Digest::MD5.hexdigest(pair.upcase + pin_string)
42
+ Digest::MD5.hexdigest(pair.upcase + pin_string).upcase
38
43
  end
39
44
 
40
45
  def serve(client)
41
- if client.gets =~ /pairingcode=#{@expected}/i
42
- client.print "HTTP/1.1 200 OK\r\n" +
43
- "Content-Length: #{@pairing_string.length}\r\n\r\n"
46
+ data = client.gets
47
+ peer_addr = client.peeraddr[2]
48
+ browser = DACPClient::Browser.new
49
+ browser.browse
50
+ @peer = browser.devices.find do |device|
51
+ device.host == peer_addr
52
+ end
53
+ if data =~ /pairingcode=#{@expected}/i && @peer
54
+ client.print "HTTP/1.1 200 OK\n" +
55
+ "Content-Length: #{@pairing_string.length}\n\n"
44
56
  client.print @pairing_string
45
57
  client.close
46
58
  stop
@@ -64,11 +76,7 @@ module DACPClient
64
76
  end
65
77
 
66
78
  def generate_pairing_string(pair, name, device_type)
67
- DMAPBuilder.cmpa do
68
- cmpg pair
69
- cmnm name
70
- cmty device_type
71
- end.to_dmap
79
+ PairInfo.build_dmap(pairing_code: pair, name: name, type: device_type)
72
80
  end
73
81
  end
74
82
  end
@@ -1,4 +1,4 @@
1
1
  # The DACPClient module
2
2
  module DACPClient
3
- VERSION = '0.2.6'
3
+ VERSION = '0.2.9'
4
4
  end