dacpclient 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 851490abf8148566e8348090625db3b72d2d6328
4
+ data.tar.gz: c845ff13c853f52927eea02ead5465469a461f1e
5
+ SHA512:
6
+ metadata.gz: 528948172ba3b6bd76e0cb3cc4ccf15508df58a86267035e65a07b2e489fa53cbcfe57cc868f4641bb18d3921878a04edb61b523f1fc9b01761cca563cc0320b
7
+ data.tar.gz: 1530e1c99b1d139467fd9638c617fa1fd95f4793abff03a89cad81a07052fa014a7ced470df0a12328663e3d5fd2d96aacbeaedf71123a75fabaa7a1d41a71f9
data/.rubocop.yml ADDED
@@ -0,0 +1,5 @@
1
+ # Avoid methods longer than 10 lines of code
2
+ MethodLength:
3
+ Enabled: false
4
+ LineLength:
5
+ Enabled: false
data/Gemfile ADDED
@@ -0,0 +1,14 @@
1
+ ruby '2.0.0'
2
+ gemspec
3
+
4
+ source 'https://rubygems.org'
5
+
6
+ group :test do
7
+ gem 'pry'
8
+ gem 'minitest', '~> 5.0.6'
9
+ #gem 'coveralls', require: false
10
+ gem 'rake'
11
+ gem 'rubocop', github: 'bbatsov/rubocop', ref: '68b73d5c38a' #'~> 0.12.0'
12
+ gem 'simplecov', require: false
13
+ #gem 'simple_mock'
14
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,59 @@
1
+ GIT
2
+ remote: git://github.com/bbatsov/rubocop.git
3
+ revision: 68b73d5c38ad86f8702302d440437dbd77f677c0
4
+ ref: 68b73d5c38a
5
+ specs:
6
+ rubocop (0.11.0)
7
+ backports (~> 3.3.3)
8
+ parser (~> 2.0.0.pre6)
9
+ powerpack (= 0.0.1)
10
+ rainbow (>= 1.1.4)
11
+
12
+ PATH
13
+ remote: .
14
+ specs:
15
+ dacpclient (0.1.0)
16
+ dnssd (~> 2.0)
17
+
18
+ GEM
19
+ remote: https://rubygems.org/
20
+ specs:
21
+ ast (1.1.0)
22
+ backports (3.3.3)
23
+ coderay (1.0.9)
24
+ dnssd (2.0)
25
+ github-markup (0.7.5)
26
+ method_source (0.8.2)
27
+ minitest (5.0.6)
28
+ multi_json (1.7.8)
29
+ parser (2.0.0.pre6)
30
+ ast (~> 1.1)
31
+ slop (~> 3.4, >= 3.4.5)
32
+ powerpack (0.0.1)
33
+ pry (0.9.12.2)
34
+ coderay (~> 1.0.5)
35
+ method_source (~> 0.8)
36
+ slop (~> 3.4)
37
+ rainbow (1.1.4)
38
+ rake (10.1.0)
39
+ redcarpet (3.0.0)
40
+ simplecov (0.7.1)
41
+ multi_json (~> 1.0)
42
+ simplecov-html (~> 0.7.1)
43
+ simplecov-html (0.7.1)
44
+ slop (3.4.6)
45
+ yard (0.8.7)
46
+
47
+ PLATFORMS
48
+ ruby
49
+
50
+ DEPENDENCIES
51
+ dacpclient!
52
+ github-markup
53
+ minitest (~> 5.0.6)
54
+ pry
55
+ rake
56
+ redcarpet
57
+ rubocop!
58
+ simplecov
59
+ yard
data/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ Copyright (c) 2011-2013, Jurriaan Pruis <email@jurriaanpruis.nl>
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+ * Redistributions of source code must retain the above copyright
7
+ notice, this list of conditions and the following disclaimer.
8
+ * Redistributions in binary form must reproduce the above copyright
9
+ notice, this list of conditions and the following disclaimer in the
10
+ documentation and/or other materials provided with the distribution.
11
+ * Neither the name of the <organization> nor the
12
+ names of its contributors may be used to endorse or promote products
13
+ derived from this software without specific prior written permission.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
16
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
17
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL Jurriaan Pruis BE LIABLE FOR ANY
19
+ DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
20
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
21
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
22
+ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
24
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ A DACP (iTunes Remote protocol) client written in the wonderful Ruby language
2
+
3
+ Look at the remoteclient.rb file for an example client.
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+ require 'rubocop'
4
+ require 'yard'
5
+ YARD::Rake::YardocTask.new
6
+
7
+ # Rake::TestTask.new do |t|
8
+ # t.libs << 'lib/docparser'
9
+ # t.test_files = FileList['test/lib/**/*_test.rb']
10
+ # t.verbose = true
11
+ # end
12
+
13
+ task test: :rubocop
14
+
15
+ task :rubocop do
16
+ puts "Running Rubocop #{Rubocop::Version::STRING}"
17
+ args = FileList['**/*.rb', 'Rakefile', 'dacpclient.gemspec']
18
+ cli = Rubocop::CLI.new
19
+ fail unless cli.run(args) == 0
20
+ end
21
+
22
+ task default: :test
data/TODO ADDED
@@ -0,0 +1,3 @@
1
+ - Add tests (DMAPBuilder)
2
+ - Add more tagdefinitions
3
+ - Documentation :)
@@ -0,0 +1,28 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'dacpclient/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'dacpclient'
7
+ spec.version = DACPClient::VERSION
8
+ spec.authors = ['Jurriaan Pruis']
9
+ spec.email = ['email@jurriaanpruis.nl']
10
+ spec.description = 'A DACP (iTunes Remote protocol) client written in the wonderful Ruby language'
11
+ spec.summary = 'A DACP (iTunes Remote protocol) client written in the wonderful Ruby language'
12
+ spec.homepage = 'https://github.com/jurriaan/ruby-dacpclient'
13
+ spec.license = 'MIT'
14
+ spec.platform = Gem::Platform::RUBY
15
+
16
+ spec.files = `git ls-files`.split($RS)
17
+ spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(/^(test|spec|features)\//)
19
+ spec.require_paths = ['lib']
20
+ spec.extra_rdoc_files = ['README.md', 'LICENSE']
21
+
22
+ spec.add_runtime_dependency 'dnssd', '~> 2.0'
23
+
24
+ spec.add_development_dependency 'yard'
25
+ spec.add_development_dependency 'redcarpet'
26
+ spec.add_development_dependency 'github-markup'
27
+ spec.required_ruby_version = '>= 2.0.0'
28
+ end
@@ -0,0 +1,196 @@
1
+ $LOAD_PATH.unshift __dir__
2
+ require 'rubygems'
3
+ require 'bundler/setup'
4
+ require 'digest'
5
+ require 'net/http'
6
+ require_relative 'pairingserver'
7
+ require_relative 'dmapparser'
8
+ require_relative 'dmapbuilder'
9
+ require 'uri'
10
+ require 'cgi'
11
+
12
+ module DACPClient
13
+ class Client
14
+
15
+ def initialize(name, host = 'localhost', port = 3689)
16
+ @client = Net::HTTP.new(host, port)
17
+ @name = name
18
+ @host = host
19
+ @port = port
20
+ @service = nil
21
+ @session_id = nil
22
+ @mediarevision = 1
23
+ end
24
+
25
+ def pair(pin = nil)
26
+ pairingserver = PairingServer.new(@name, '0.0.0.0', 1024)
27
+ pairingserver.pin = pin unless pin.nil?
28
+ pairingserver.start
29
+ end
30
+
31
+ def self.get_guid(name)
32
+ d = Digest::SHA2.hexdigest(name)
33
+ d[0..15]
34
+ end
35
+
36
+ def serverinfo
37
+ do_action('server-info')
38
+ end
39
+
40
+ def login(pin = nil)
41
+ response = do_action(:login, { 'pairing-guid' => '0x' + Client.get_guid(@name) })
42
+ @session_id = response[:mlid]
43
+ response
44
+ rescue DACPForbiddenError => e
45
+ puts "#{e.result.message} error: Cannot login, starting pairing process"
46
+ pin = 4.times.map { Random.rand(10) } if pin.nil?
47
+ pair(pin)
48
+ retry
49
+ end
50
+
51
+ def content_codes
52
+ do_action('content-codes', {}, true)
53
+ end
54
+
55
+ def play
56
+ do_action(:play)
57
+ end
58
+
59
+ def playpause
60
+ do_action(:playpause)
61
+ end
62
+
63
+ def stop
64
+ do_action(:stop)
65
+ end
66
+
67
+ def pause
68
+ do_action(:pause)
69
+ end
70
+
71
+ def seek(ms)
72
+ do_action(:setproperty, 'dacp.playingtime' => ms)
73
+ end
74
+
75
+ def status(wait = false)
76
+ revision = wait ? @mediarevision : 1
77
+ result = do_action(:playstatusupdate, 'revision-number' => revision)
78
+ @mediarevision = result[:cmsr]
79
+ result
80
+ end
81
+
82
+ def next
83
+ do_action(:nextitem)
84
+ end
85
+
86
+ def prev
87
+ do_action(:previtem)
88
+ end
89
+
90
+ def get_volume
91
+ response = do_action(:getproperty, properties: 'dmcp.volume')
92
+ response[:cmvo]
93
+ end
94
+
95
+ def set_volume(volume)
96
+ do_action(:setproperty, 'dmcp.volume' => volume)
97
+ end
98
+
99
+ def get_repeat
100
+ response = do_action(:getproperty, properties: 'dacp.repeatstate')
101
+ response[:carp]
102
+ end
103
+
104
+ def set_repeat(volume)
105
+ do_action(:setproperty, 'dmcp.volume' => volume)
106
+ end
107
+
108
+ def get_shuffle
109
+ response = do_action(:getproperty, properties: 'dmcp.shufflestate')
110
+ response[:cash]
111
+ end
112
+
113
+ def set_shuffle(volume)
114
+ do_action(:setproperty, 'dmcp.volume' => volume)
115
+ end
116
+
117
+ def ctrl_int
118
+ do_action('ctrl-int', {}, false)
119
+ end
120
+
121
+ def logout
122
+ do_action(:logout, {}, false)
123
+ end
124
+
125
+ def queue(id)
126
+ do_action 'playqueue-edit', { command: 'add', query: "\'dmap.itemid:#{id}\'" }
127
+ end
128
+
129
+ def clear_queue
130
+ do_action 'playqueue-edit', { command: 'clear' }
131
+ end
132
+
133
+ def list_queue
134
+ do_action 'playqueue-contents', {}
135
+ end
136
+
137
+ def databases
138
+ do_action 'databases', {}, true
139
+ end
140
+
141
+ def playlists(db)
142
+ do_action "databases/#{db}/containers", {}, true
143
+ end
144
+
145
+ def artwork(database, id, width = 320, height = 320)
146
+ do_action "databases/#{database}/items/#{id}/extra_data/artwork", { mw: width, mh: height }, true
147
+ end
148
+
149
+ def now_playing_artwork(width = 320, height = 320)
150
+ do_action :nowplayingartwork, { mw: width, mh: height }
151
+ end
152
+
153
+ def search(db, container, search)
154
+ words = search.split
155
+ queries = []
156
+ queries.push(words.map { |v| "\'dmap.itemname:*#{v}*\'" }.join('+'))
157
+ # queries.push(words.map{|v| "\'daap.songartist:*#{v}*\'"}.join('+'))
158
+ query = '(' + queries.map { |q| "(#{q})" }.join(',') + ')'
159
+ do_action "databases/#{db}/containers/#{container}/items", { type: 'music', sort: 'album', meta: 'dmap.itemid,dmap.itemname,daap.songartist,daap.songalbum', query: query }, true
160
+ end
161
+
162
+ private
163
+
164
+ def do_action(action, params = {}, cleanurl = false)
165
+ action = '/' + action.to_s
166
+ unless @session_id.nil?
167
+ params['session-id'] = @session_id
168
+ action = '/ctrl-int/1' + action unless cleanurl
169
+ end
170
+ params = params.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join '&'
171
+ uri = URI::HTTP.build({ host: @host, port: @port, path: action, query: params })
172
+ req = Net::HTTP::Get.new(uri.request_uri)
173
+ req.add_field('Viewer-Only-Client', '1')
174
+ res = Net::HTTP.new(uri.host, uri.port).start { |http| http.request(req) }
175
+ if res.kind_of?(Net::HTTPServiceUnavailable) || res.kind_of?(Net::HTTPForbidden)
176
+ raise DACPForbiddenError.new(res)
177
+ elsif !res.kind_of?(Net::HTTPSuccess)
178
+ p res
179
+ return nil
180
+ end
181
+
182
+ if res['Content-Type'] == 'application/x-dmap-tagged'
183
+ DMAPParser.parse(res.body)
184
+ else
185
+ res.body
186
+ end
187
+ end
188
+ end
189
+
190
+ class DACPForbiddenError < StandardError
191
+ attr_reader :result
192
+ def initialize(res)
193
+ @result = res
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,36 @@
1
+ module DACPClient
2
+ class DMAPBuilder
3
+ attr_reader :result
4
+
5
+ def initialize
6
+ @dmap_stack = []
7
+ end
8
+
9
+ def self.method_missing(method, *args, &block)
10
+ self.new.send(method, *args, &block)
11
+ end
12
+
13
+ def method_missing(method, *args, &block)
14
+ return super if method.to_s.length != 4 || (tag = DMAPParser::Types.find { |a| a.tag.to_s == method.to_s }).nil?
15
+ if block_given?
16
+ if tag.type == :container
17
+ @dmap_stack << DMAPParser::TagContainer.new(tag)
18
+ instance_eval(&block)
19
+ if @dmap_stack.length > 1
20
+ @dmap_stack.last.value << @dmap_stack.pop
21
+ else
22
+ return @result = @dmap_stack.pop
23
+ end
24
+ else
25
+ raise "Tag #{method} is not a container type"
26
+ end
27
+ else
28
+ if @dmap_stack.length > 0
29
+ @dmap_stack.last.value << DMAPParser::Tag.new(tag, args.size > 1 ? args : args.first)
30
+ else
31
+ raise 'Cannot build DMAP without a valid container'
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,65 @@
1
+ module DACPClient
2
+ class DMAPConverter
3
+ class << self
4
+ def bin_to_byte(data)
5
+ data.unpack('C').first
6
+ end
7
+
8
+ def bin_to_long(data)
9
+ (bin_to_int(data[0..3]) << 32) + bin_to_int(data[4..7])
10
+ end
11
+
12
+ def bin_to_int(data)
13
+ data.unpack('N').first
14
+ end
15
+
16
+ def bin_to_short(data)
17
+ data.unpack('n').first
18
+ end
19
+
20
+ def bin_to_bool(data)
21
+ data == "\x01"
22
+ end
23
+
24
+ def bin_to_version(data)
25
+ data.unpack('nCC').join '.'
26
+ end
27
+
28
+ def bin_to_hex(data)
29
+ data.bytes.reduce('') { |a, e| a += sprintf('%02X', e) }
30
+ end
31
+
32
+ def bool_to_bin(data)
33
+ if data.true?
34
+ "\x01"
35
+ else
36
+ "\x00"
37
+ end
38
+ end
39
+
40
+ def int_to_bin(data)
41
+ [data.to_i].pack 'N'
42
+ end
43
+
44
+ def byte_to_bin(data)
45
+ [data.to_i].pack 'C'
46
+ end
47
+
48
+ def long_to_bin(data)
49
+ [data >> 32, data & 0xfffffff].pack 'NN'
50
+ end
51
+
52
+ def short_to_bin(data)
53
+ [data.to_i].pack 'n'
54
+ end
55
+
56
+ def version_to_bin(data)
57
+ data.split('.').pack 'nCC'
58
+ end
59
+
60
+ def hex_to_bin(data)
61
+ [data].pack 'H*'
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,82 @@
1
+ require_relative 'tagdefinitions'
2
+ require_relative 'dmapconverter'
3
+
4
+ require 'stringio'
5
+ module DACPClient
6
+ class DMAPParser
7
+
8
+ def self.parse(response)
9
+ return nil if response.nil? || response.length < 8
10
+ response = StringIO.new(response)
11
+ ret = TagContainer.new
12
+ key = response.read(4)
13
+ ret.type = Types.find { |a| a.tag == key }
14
+ response.read(4) # ignore length for now
15
+ ret.value = parse_container(response)
16
+ ret
17
+ end
18
+
19
+ private
20
+
21
+ def self.parse_container(response)
22
+ values = []
23
+
24
+ until response.eof?
25
+ key = response.read(4)
26
+ length = DMAPConverter.bin_to_int(response.read(4))
27
+ data = response.read(length)
28
+ tag = Types.find { |a| a.tag.to_s == key }
29
+ # puts "#{key} (#{length}): #{data.inspect}"
30
+ p data if !tag.nil? && tag.tag.to_s == 'msas'
31
+ values << if !tag.nil?
32
+ case tag.type
33
+ when :container
34
+ TagContainer.new(tag, parse_container(StringIO.new(data)))
35
+ when :byte
36
+ Tag.new(tag, DMAPConverter.bin_to_byte(data))
37
+ when :uint16, :short
38
+ Tag.new(tag, DMAPConverter.bin_to_short(data))
39
+ when :uint32
40
+ Tag.new(tag, DMAPConverter.bin_to_int(data))
41
+ when :uint64
42
+ Tag.new(tag, DMAPConverter.bin_to_long(data))
43
+ when :bool
44
+ Tag.new(tag, DMAPConverter.bin_to_bool(data))
45
+ when :hex
46
+ Tag.new(tag, DMAPConverter.bin_to_hex(data))
47
+ when :string
48
+ Tag.new(tag, data)
49
+ when :date
50
+ Tag.new tag, Time.at(DMAPConverter.bin_to_int(data))
51
+ when :version
52
+ Tag.new tag, DMAPConverter.bin_to_version(data)
53
+ else
54
+ puts "Unknown type #{tag.type}"
55
+ Tag.new(tag, parseunknown(data))
56
+ end
57
+ else
58
+ # puts "Unknown key #{key}"
59
+ Tag.new(TagDefinition.new(key, :unknown, "unknown (#{data.bytesize})"), parseunknown(data))
60
+ end
61
+ end
62
+ values
63
+ end
64
+
65
+ def self.parseunknown(data)
66
+ if data =~ /[^\x20-\x7e]/
67
+ if data.bytesize == 1
68
+ DMAPConverter.bin_to_byte(data)
69
+ elsif data.bytesize == 4
70
+ DMAPConverter.bin_to_int(data)
71
+ elsif data.bytesize == 8
72
+ DMAPConverter.bin_to_long(data)
73
+ else
74
+ data
75
+ end
76
+ else
77
+ data
78
+ end
79
+ end
80
+
81
+ end
82
+ end
@@ -0,0 +1,66 @@
1
+ require 'socket'
2
+ require 'dnssd'
3
+ require 'digest'
4
+ module DACPClient
5
+ class PairingServer
6
+ attr_accessor :pin, :device_type
7
+ def initialize(name, host, port = 1024)
8
+ @name = name
9
+ @port = port
10
+ @host = host
11
+ @pair = Client.get_guid(@name)
12
+ @pin = [0, 0, 0, 0]
13
+ @device_type = 'iPod'
14
+ end
15
+
16
+ def start
17
+ name = @name
18
+ pair = @pair
19
+ device_type = @device_type
20
+ puts "Pairing started (pincode=#{@pin.join})"
21
+ txtrecord = DNSSD::TextRecord.new({
22
+ 'DvNm' => @name,
23
+ 'Revm' => '10000',
24
+ 'DvTy' => @device_type,
25
+ 'RemN' => 'Remote',
26
+ 'txtvers' => '1',
27
+ 'Pair' => @pair
28
+ })
29
+
30
+ pairingstring = DMAPBuilder.cmpa do
31
+ cmpg pair
32
+ cmnm name
33
+ cmty device_type
34
+ end.to_dmap
35
+
36
+ expected = PairingServer.generate_pin_challenge(@pair, @pin)
37
+ server = TCPServer.open(@host, @port)
38
+ @service = DNSSD.register!(@name, '_touch-remote._tcp', 'local', @port, txtrecord)
39
+
40
+ while (client = server.accept)
41
+ get = client.gets
42
+ code = get.match(/pairingcode=([^&]*)/)[1]
43
+
44
+ if code == expected
45
+ client.print "HTTP/1.1 200 OK\r\nContent-Length: #{pairingstring.length}\r\n\r\n#{pairingstring}"
46
+ puts 'Pairing succeeded :)'
47
+ client.close
48
+ break
49
+ else
50
+ puts 'Wrong pincode entered'
51
+ client.print "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"
52
+ end
53
+ client.close
54
+ end
55
+ server.close
56
+
57
+ sleep 1 # sleep so iTunes accepts our login
58
+ end
59
+
60
+ def self.generate_pin_challenge(pair, pin)
61
+ pin_string = pin.map { |i| "#{i}\x00" }.join
62
+ Digest::MD5.hexdigest(pair + pin_string).upcase
63
+ end
64
+
65
+ end
66
+ end
@@ -0,0 +1,252 @@
1
+ # encoding: UTF-8
2
+
3
+ module DACPClient
4
+ class DMAPParser
5
+
6
+ Tag = Struct.new(:type, :value) do
7
+ def inspect(level = 0)
8
+ "#{' ' * level}#{type}: #{value}"
9
+ end
10
+
11
+ def to_dmap
12
+ value = self.value
13
+ value = case type.type
14
+ when :container
15
+ value.reduce('') { |a, e| a += e.to_dmap }
16
+ when :byte
17
+ DMAPConverter.byte_to_bin value
18
+ when :uint16, :short
19
+ DMAPConverter.short_to_bin value
20
+ when :uint32
21
+ DMAPConverter.int_to_bin value
22
+ when :uint64
23
+ DMAPConverter.long_to_bin value
24
+ when :bool
25
+ DMAPConverter.bool_to_bin value
26
+ when :hex
27
+ DMAPConverter.hex_to_bin value
28
+ when :string
29
+ value
30
+ when :date
31
+ DMAPConverter.int_to_bin value.to_i
32
+ when :version
33
+ DMAPConverter.version_to_bin value.to_i
34
+ else
35
+ puts "Unknown type #{tag.type}"
36
+ # Tag.new tag, parseunknown(data)
37
+ value
38
+ end
39
+ type.tag.to_s + [value.length].pack('N') + value
40
+ end
41
+ end
42
+
43
+ class TagContainer < Tag
44
+
45
+ def initialize(type = nil, value = [])
46
+ super type, value
47
+ end
48
+
49
+ def inspect(level = 0)
50
+ "#{' ' * level}#{type}:\n" + value.reduce('') { |a, e| a + e.inspect(level + 1).chomp + "\n" }
51
+ end
52
+
53
+ def get_value(key)
54
+ key = key.to_s
55
+ val = value.find { |e| e.type.tag == key }
56
+ val = value.find { |e| e.type.name == key } if val.nil?
57
+ if val.type.type == :container
58
+ val
59
+ else
60
+ val.value
61
+ end
62
+ end
63
+
64
+ alias_method :[], :get_value
65
+
66
+ def has?(key)
67
+ key = key.to_s
68
+ val = value.find { |e| e.type.tag == key }
69
+ val = value.find { |e| e.type.name == key } if val.nil?
70
+ !val.nil?
71
+ end
72
+
73
+ def method_missing(method, *arguments, &block)
74
+ value.each do |dmap|
75
+ return dmap.value if dmap.type.tag.to_s == method.to_s
76
+ end
77
+ super
78
+ end
79
+ end
80
+
81
+ TagDefinition = Struct.new(:tag, :type, :name) do
82
+ def inspect
83
+ "#{tag} (#{name}: #{type})"
84
+ end
85
+
86
+ def to_s
87
+ "#{tag} (#{name}: #{type})"
88
+ end
89
+ end
90
+
91
+ # Sources:
92
+ # https://github.com/chendo/dmap-ng/blob/master/lib/dmap/tag_definitions.rb
93
+ # https://code.google.com/p/ytrack/wiki/DMAP
94
+ # https://code.google.com/p/tunesremote-se/wiki/ContentCodes
95
+ # /content-codes
96
+ Types = [
97
+ TagDefinition.new('mcon', :container, 'dmap.container'),
98
+ TagDefinition.new('msrv', :container, 'dmap.serverinforesponse'),
99
+ TagDefinition.new('msml', :container, 'dmap.msml'),
100
+ TagDefinition.new('mccr', :container, 'dmap.contentcodesresponse'),
101
+ TagDefinition.new('mdcl', :container, 'dmap.dictionary'),
102
+ TagDefinition.new('mlog', :container, 'dmap.loginresponse'),
103
+ TagDefinition.new('mupd', :container, 'dmap.updateresponse'),
104
+ TagDefinition.new('avdb', :container, 'daap.serverdatabases'),
105
+ TagDefinition.new('mlcl', :container, 'dmap.listing'),
106
+ TagDefinition.new('mlit', :container, 'dmap.listingitem'),
107
+ TagDefinition.new('mbcl', :container, 'dmap.bag'),
108
+ TagDefinition.new('adbs', :container, 'daap.returndatabasesongs'),
109
+ TagDefinition.new('aply', :container, 'daap.databaseplaylists'),
110
+ TagDefinition.new('apso', :container, 'daap.playlistsongs'),
111
+ TagDefinition.new('mudl', :container, 'dmap.deletedidlisting'),
112
+ TagDefinition.new('abro', :container, 'daap.databasebrowse'),
113
+ TagDefinition.new('abal', :container, 'daap.browsealbumlisting'),
114
+ TagDefinition.new('abar', :container, 'daap.browseartistlisting'),
115
+ TagDefinition.new('abcp', :container, 'daap.browsecomposerlisting'),
116
+ TagDefinition.new('abgn', :container, 'daap.browsegenrelisting'),
117
+ TagDefinition.new('prsv', :container, 'daap.resolve'),
118
+ TagDefinition.new('arif', :container, 'daap.resolveinfo'),
119
+ TagDefinition.new('casp', :container, 'dacp.speakers'),
120
+ TagDefinition.new('caci', :container, 'dacp.controlint'),
121
+ TagDefinition.new('cmpa', :container, 'dacp.pairinganswer'),
122
+ TagDefinition.new('cacr', :container, 'dacp.cacr'),
123
+ TagDefinition.new('cmcp', :container, 'dmcp.controlprompt'),
124
+ TagDefinition.new('cmgt', :container, 'dmcp.getpropertyresponse'),
125
+ TagDefinition.new('cmst', :container, 'dmcp.status'),
126
+ TagDefinition.new('agal', :container, 'daap.albumgrouping'),
127
+ TagDefinition.new('minm', :string, 'dmap.itemname'),
128
+ TagDefinition.new('msts', :string, 'dmap.statusstring'),
129
+ TagDefinition.new('mcna', :string, 'dmap.contentcodesname'),
130
+ TagDefinition.new('asal', :string, 'daap.songalbum'),
131
+ TagDefinition.new('asaa', :string, 'daap.songalbumartist'),
132
+ TagDefinition.new('asar', :string, 'daap.songartist'),
133
+ TagDefinition.new('ascm', :string, 'daap.songcomment'),
134
+ TagDefinition.new('asfm', :string, 'daap.songformat'),
135
+ TagDefinition.new('aseq', :string, 'daap.songeqpreset'),
136
+ TagDefinition.new('asgn', :string, 'daap.songgenre'),
137
+ TagDefinition.new('asdt', :string, 'daap.songdescription'),
138
+ TagDefinition.new('asul', :string, 'daap.songdataurl'),
139
+ TagDefinition.new('ceWM', :string, 'com.apple.itunes.welcome-message'), # not a official name?
140
+ TagDefinition.new('ascp', :string, 'daap.songcomposer'),
141
+ TagDefinition.new('assu', :string, 'daap.sortartist'),
142
+ TagDefinition.new('assa', :string, 'daap.sortalbum'),
143
+ TagDefinition.new('agrp', :string, 'daap.songgrouping'),
144
+ TagDefinition.new('cann', :string, 'daap.nowplayingtrack'),
145
+ TagDefinition.new('cana', :string, 'daap.nowplayingartist'),
146
+ TagDefinition.new('canl', :string, 'daap.nowplayingalbum'),
147
+ TagDefinition.new('cang', :string, 'daap.nowplayinggenre'),
148
+ TagDefinition.new('cmnm', :string, 'dacp.devicename'),
149
+ TagDefinition.new('cmty', :string, 'dacp.devicetype'),
150
+ TagDefinition.new('cmpg', :hex, 'dacp.pairingguid'), # hex string
151
+ TagDefinition.new('mper', :uint64, 'dmap.persistentid'),
152
+ TagDefinition.new('canp', :uint64, 'dacp.nowplaying'),
153
+ TagDefinition.new('cmpy', :uint64, 'dacp.passguid'),
154
+ TagDefinition.new('mstt', :uint32, 'dmap.status'), # http status??
155
+ TagDefinition.new('mcnm', :uint32, 'dmap.contentcodesnumber'),
156
+ TagDefinition.new('miid', :uint32, 'dmap.itemid'),
157
+ TagDefinition.new('mcti', :uint32, 'dmap.containeritemid'),
158
+ TagDefinition.new('mpco', :uint32, 'dmap.parentcontainerid'),
159
+ TagDefinition.new('mimc', :uint32, 'dmap.itemcount'),
160
+ TagDefinition.new('mrco', :uint32, 'dmap.returnedcount'),
161
+ TagDefinition.new('mtco', :uint32, 'dmap.containercount'),
162
+ TagDefinition.new('mstm', :uint32, 'dmap.timeoutinterval'),
163
+ TagDefinition.new('msdc', :uint32, 'dmap.databasescount'),
164
+ TagDefinition.new('msma', :uint32, 'dmap.speakermachineaddress'),
165
+ TagDefinition.new('mlid', :uint32, 'dmap.sessionid'),
166
+ TagDefinition.new('assr', :uint32, 'daap.songsamplerate'),
167
+ TagDefinition.new('assz', :uint32, 'daap.songsize'),
168
+ TagDefinition.new('asst', :uint32, 'daap.songstarttime'),
169
+ TagDefinition.new('assp', :uint32, 'daap.songstoptime'),
170
+ TagDefinition.new('astm', :uint32, 'daap.songtime'),
171
+ TagDefinition.new('msto', :uint32, 'dmap.utcoffset'),
172
+ TagDefinition.new('cmsr', :uint32, 'dmcp.mediarevision'),
173
+ TagDefinition.new('caas', :uint32, 'dacp.albumshuffle'),
174
+ TagDefinition.new('caar', :uint32, 'dacp.albumrepeat'),
175
+ TagDefinition.new('cant', :uint32, 'dacp.remainingtime'),
176
+ TagDefinition.new('cmmk', :uint32, 'dmcp.mediakind'),
177
+ TagDefinition.new('cast', :uint32, 'dacp.tracklength'),
178
+ TagDefinition.new('asai', :uint32, 'daap.songalbumid'),
179
+ TagDefinition.new('aeNV', :uint32, 'com.apple.itunes.norm-volume'),
180
+ TagDefinition.new('cmvo', :uint32, 'dmcp.volume'),
181
+ TagDefinition.new('mcty', :uint16, 'dmap.contentcodestype'),
182
+ TagDefinition.new('asbt', :uint16, 'daap.songsbeatsperminute'),
183
+ TagDefinition.new('asbr', :uint16, 'daap.songbitrate'),
184
+ TagDefinition.new('asdc', :uint16, 'daap.songdisccount'),
185
+ TagDefinition.new('asdn', :uint16, 'daap.songdiscnumber'),
186
+ TagDefinition.new('astc', :uint16, 'daap.songtrackcount'),
187
+ TagDefinition.new('astn', :uint16, 'daap.songtracknumber'),
188
+ TagDefinition.new('asyr', :uint16, 'daap.songyear'),
189
+ TagDefinition.new('ated', :uint16, 'daap.supportsextradata'),
190
+ TagDefinition.new('asgr', :uint16, 'daap.supportsgroups'),
191
+ TagDefinition.new('mikd', :byte, 'dmap.itemkind'),
192
+ TagDefinition.new('casu', :byte, 'dacp.su'),
193
+ TagDefinition.new('msau', :byte, 'dmap.authenticationmethod'),
194
+ TagDefinition.new('mstu', :byte, 'dmap.updatetype'),
195
+ TagDefinition.new('asrv', :byte, 'daap.songrelativevolume'),
196
+ TagDefinition.new('asur', :byte, 'daap.songuserrating'),
197
+ TagDefinition.new('asdk', :byte, 'daap.songdatakind'),
198
+ TagDefinition.new('caps', :byte, 'dacp.playstatus'),
199
+ TagDefinition.new('cash', :byte, 'dacp.shufflestate'),
200
+ TagDefinition.new('carp', :byte, 'dacp.repeatstate'),
201
+ TagDefinition.new('muty', :byte, 'dmap.updatetype'),
202
+ TagDefinition.new("f\215ch", :byte, 'dmap.haschildcontainers'),
203
+ TagDefinition.new('msas', :byte, 'dmap.authenticationschemes'),
204
+ TagDefinition.new('cavs', :bool, 'dacp.visualizer'), # Source: https://code.google.com/p/tunesremote-plus/source/browse/trunk/src/org/tunesremote/daap/Status.java
205
+ TagDefinition.new('cafs', :bool, 'dacp.fullscreen'),
206
+ TagDefinition.new('ceGS', :bool, 'com.apple.itunes.genius-selectable'),
207
+ TagDefinition.new('mslr', :bool, 'dmap.loginrequired'),
208
+ TagDefinition.new('msal', :bool, 'dmap.supportsautologout'),
209
+ TagDefinition.new('msup', :bool, 'dmap.supportsupdate'),
210
+ TagDefinition.new('mspi', :bool, 'dmap.supportspersistenids'),
211
+ TagDefinition.new('msex', :bool, 'dmap.supportsextensions'),
212
+ TagDefinition.new('msbr', :bool, 'dmap.supportsbrowse'),
213
+ TagDefinition.new('msqy', :bool, 'dmap.supportsquery'),
214
+ TagDefinition.new('msix', :bool, 'dmap.supportsindex'),
215
+ TagDefinition.new('msrs', :bool, 'dmap.supportsresolve'),
216
+ TagDefinition.new('asco', :bool, 'daap.songcompliation'),
217
+ TagDefinition.new('asdb', :bool, 'daap.songdisabled'),
218
+ TagDefinition.new('abpl', :bool, 'daap.baseplaylist'),
219
+ TagDefinition.new('aeSP', :bool, 'com.apple.itunes.smart-playlist'),
220
+ TagDefinition.new('aePP', :bool, 'com.apple.itunes.is-podcast-playlist'),
221
+ TagDefinition.new('aePS', :bool, 'com.apple.itunes.special-playlist'),
222
+ TagDefinition.new('aeSG', :bool, 'com.apple.itunes.saved-genius'),
223
+ TagDefinition.new('aeFP', :bool, 'com.apple.itunes.req-fplay'),
224
+ TagDefinition.new('aeHV', :bool, 'com.apple.itunes.has-video'),
225
+ TagDefinition.new('caia', :bool, 'dacp.isavailiable'),
226
+ TagDefinition.new('ceVO', :bool, 'com.apple.itunes.voting-enabled'), # not an official name
227
+ TagDefinition.new('aeSV', :version, 'com.apple.itunes.music-sharing-version'),
228
+ TagDefinition.new('mpro', :version, 'dmap.protocolversion'),
229
+ TagDefinition.new('apro', :version, 'daap.protocolversion'),
230
+ TagDefinition.new('musr', :version, 'dmap.serverrevision'),
231
+ TagDefinition.new('mstc', :date, 'dmap.utc-time'),
232
+ TagDefinition.new('asda', :date, 'daap.songdateadded'),
233
+ TagDefinition.new('asdm', :date, 'daap.songdatemodified'),
234
+ TagDefinition.new('ceJC', :bool, 'com.apple.itunes.jukebox-client-vote'),
235
+ TagDefinition.new('ceJI', :bool, 'com.apple.itunes.jukebox-current'),
236
+ TagDefinition.new('ceJS', :uint16, 'com.apple.itunes.jukebox-score'),
237
+ TagDefinition.new('ceJV', :bool, 'com.apple.itunes.jukebox-vote'),
238
+ TagDefinition.new('ceQR', :container, 'com.apple.itunes.playqueue-contents-response'),
239
+ TagDefinition.new('ceQS', :container, 'com.apple.itunes.playqueue-contents-???'),
240
+ TagDefinition.new('ceQs', :uint64, 'com.apple.itunes.playqueue-id'), # non-official name
241
+ TagDefinition.new('ceQa', :string, 'com.apple.itunes.playqueue-album'),
242
+ TagDefinition.new('ceQg', :string, 'com.apple.itunes.playqueue-genre'),
243
+ TagDefinition.new('ceQn', :string, 'com.apple.itunes.playqueue-name'),
244
+ TagDefinition.new('ceQr', :string, 'com.apple.itunes.playqueue-artist'),
245
+ TagDefinition.new('msml', :container, 'msml'),
246
+ TagDefinition.new('aeGs', :bool, 'com.apple.itunes.can-be-genius-seed'),
247
+ TagDefinition.new('aprm', :short, 'daap.playlistrepeatmode'),
248
+ TagDefinition.new('apsm', :short, 'daap.playlistshufflemode'),
249
+
250
+ ].freeze
251
+ end
252
+ end
@@ -0,0 +1,3 @@
1
+ module DACPClient
2
+ VERSION = '0.1.0'
3
+ end
data/lib/dacpclient.rb ADDED
@@ -0,0 +1,2 @@
1
+ $LOAD_PATH.unshift __dir__
2
+ require 'dacpclient/client'
data/remoteclient.rb ADDED
@@ -0,0 +1,126 @@
1
+ $LOAD_PATH.unshift __dir__
2
+ require File.expand_path('lib/dacpclient.rb', __dir__)
3
+ require 'english'
4
+ require 'socket'
5
+ include DACPClient
6
+ class CLIClient
7
+ def initialize
8
+ @client = DACPClient::Client.new("CLIClient (#{Socket.gethostname})", 'localhost', 3689)
9
+ @login = false
10
+ end
11
+
12
+ def parse_arguments(arguments)
13
+ if arguments.length > 0 && !([:parse_arguments].include?(arguments.first.to_sym)) && self.class.instance_methods(false).include?(arguments.first.to_sym)
14
+ method = arguments.first.to_sym
15
+ send method
16
+ status unless %i{help usage}.include?(method)
17
+ else
18
+ usage
19
+ end
20
+ end
21
+
22
+ def status
23
+ login
24
+ status = @client.status
25
+ if status.caps == 2
26
+ puts '[STOPPED]'
27
+ else
28
+ name = status.cann
29
+ artist = status.cana
30
+ album = status.canl
31
+ playstatus = status.caps != 4 ? 'paused' : 'playing'
32
+ remaining = status.cant
33
+ total = status.cast
34
+ current = total - remaining
35
+ puts "[#{playstatus.upcase} (#{format_time(current)}/#{format_time(total)})] #{name} - #{artist} (#{album})"
36
+ end
37
+ end
38
+
39
+ def play
40
+ login
41
+ @client.play
42
+ end
43
+
44
+ def pause
45
+ login
46
+ @client.pause
47
+ end
48
+
49
+ def playpause
50
+ login
51
+ @client.playpause
52
+ end
53
+
54
+ def next
55
+ login
56
+ @client.next
57
+ end
58
+
59
+ def prev
60
+ login
61
+ @client.prev
62
+ end
63
+
64
+ def databases
65
+ login
66
+ puts @client.databases
67
+ end
68
+
69
+ def playqueue
70
+ login
71
+ puts @client.list_queue
72
+ end
73
+
74
+ def upnext
75
+ login
76
+ items = @client.list_queue.mlcl.select { |item| item.type.tag == 'mlit' }
77
+ puts 'Up next:'
78
+ puts '--------'
79
+ puts
80
+ items.each do |item|
81
+ name = item.ceQn
82
+ artist = item.ceQr
83
+ album = item.ceQa
84
+ puts "#{name} - #{artist} (#{album})"
85
+ end
86
+ puts
87
+ end
88
+
89
+ def debug
90
+ login
91
+ require 'pry'
92
+ binding.pry
93
+ end
94
+
95
+ def usage
96
+ puts "Usage: #{$PROGRAM_NAME} [command]"
97
+ puts
98
+ puts 'Where command is one of the following:'
99
+
100
+ puts CLIClient.instance_methods(false).reject { |m| [:parse_arguments].include?(m) }
101
+ end
102
+
103
+ alias_method :previous, :prev
104
+ alias_method :help, :usage
105
+
106
+ private
107
+
108
+ def login
109
+ @client.login unless @login
110
+ @login = true
111
+ end
112
+
113
+ def format_time(millis)
114
+ seconds, millis = millis.divmod(1000)
115
+ minutes, seconds = seconds.divmod(60)
116
+ hours, minutes = minutes.divmod(60)
117
+ if hours == 0
118
+ sprintf('%02d:%02d', minutes, seconds)
119
+ else
120
+ sprintf('%02d:%02d:%02d', hours, minutes, seconds)
121
+ end
122
+ end
123
+ end
124
+
125
+ cli = CLIClient.new
126
+ cli.parse_arguments(Array(ARGV))
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dacpclient
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jurriaan Pruis
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2013-08-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dnssd
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ~>
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: yard
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: redcarpet
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '>='
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: github-markup
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '>='
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: A DACP (iTunes Remote protocol) client written in the wonderful Ruby
70
+ language
71
+ email:
72
+ - email@jurriaanpruis.nl
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files:
76
+ - README.md
77
+ - LICENSE
78
+ files:
79
+ - .rubocop.yml
80
+ - Gemfile
81
+ - Gemfile.lock
82
+ - LICENSE
83
+ - README.md
84
+ - Rakefile
85
+ - TODO
86
+ - dacpclient.gemspec
87
+ - lib/dacpclient.rb
88
+ - lib/dacpclient/client.rb
89
+ - lib/dacpclient/dmapbuilder.rb
90
+ - lib/dacpclient/dmapconverter.rb
91
+ - lib/dacpclient/dmapparser.rb
92
+ - lib/dacpclient/pairingserver.rb
93
+ - lib/dacpclient/tagdefinitions.rb
94
+ - lib/dacpclient/version.rb
95
+ - remoteclient.rb
96
+ homepage: https://github.com/jurriaan/ruby-dacpclient
97
+ licenses:
98
+ - MIT
99
+ metadata: {}
100
+ post_install_message:
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - '>='
107
+ - !ruby/object:Gem::Version
108
+ version: 2.0.0
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - '>='
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubyforge_project:
116
+ rubygems_version: 2.0.5
117
+ signing_key:
118
+ specification_version: 4
119
+ summary: A DACP (iTunes Remote protocol) client written in the wonderful Ruby language
120
+ test_files: []
121
+ has_rdoc: