o-mpd_client 0.2.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/mpd_client.rb ADDED
@@ -0,0 +1,549 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'stringio'
5
+ require 'timeout'
6
+ require 'mpd_client/version'
7
+
8
+ module MPD
9
+ HELLO_PREFIX = 'OK MPD '
10
+ ERROR_PREFIX = 'ACK '
11
+ SUCCESS = "OK\n"
12
+ NEXT = "list_OK\n"
13
+
14
+ # MPD changelog: https://github.com/MusicPlayerDaemon/MPD/blob/master/NEWS
15
+ # Protocol: https://mpd.readthedocs.io/en/latest/protocol.html
16
+ COMMANDS = {
17
+ # Status Commands
18
+ 'clearerror' => 'fetch_nothing',
19
+ 'currentsong' => 'fetch_object',
20
+ 'idle' => 'fetch_list',
21
+ 'noidle' => '',
22
+ 'status' => 'fetch_object',
23
+ 'stats' => 'fetch_object',
24
+ # Playback Option Commands
25
+ 'consume' => 'fetch_nothing',
26
+ 'crossfade' => 'fetch_nothing',
27
+ 'mixrampdb' => 'fetch_nothing',
28
+ 'mixrampdelay' => 'fetch_nothing',
29
+ 'random' => 'fetch_nothing',
30
+ 'repeat' => 'fetch_nothing',
31
+ # TODO parse the volume out
32
+ 'getvol' => 'fetch_item',
33
+ 'setvol' => 'fetch_nothing',
34
+ 'single' => 'fetch_nothing',
35
+ 'replay_gain_mode' => 'fetch_nothing',
36
+ 'replay_gain_status' => 'fetch_item',
37
+ 'volume' => 'fetch_nothing',
38
+ # Playback Control Commands
39
+ 'next' => 'fetch_nothing',
40
+ 'pause' => 'fetch_nothing',
41
+ 'play' => 'fetch_nothing',
42
+ 'playid' => 'fetch_nothing',
43
+ 'previous' => 'fetch_nothing',
44
+ 'seek' => 'fetch_nothing',
45
+ 'seekid' => 'fetch_nothing',
46
+ 'seekcur' => 'fetch_nothing',
47
+ 'stop' => 'fetch_nothing',
48
+ # Playlist Commands
49
+ 'add' => 'fetch_nothing',
50
+ 'addid' => 'fetch_item',
51
+ 'addtagid' => 'fetch_nothing',
52
+ 'cleartagid' => 'fetch_nothing',
53
+ 'clear' => 'fetch_nothing',
54
+ 'delete' => 'fetch_nothing',
55
+ 'deleteid' => 'fetch_nothing',
56
+ 'move' => 'fetch_nothing',
57
+ 'moveid' => 'fetch_nothing',
58
+ 'playlistfind' => 'fetch_songs',
59
+ 'playlistid' => 'fetch_songs',
60
+ 'playlistinfo' => 'fetch_songs',
61
+ 'playlistsearch' => 'fetch_songs',
62
+ 'plchanges' => 'fetch_songs',
63
+ 'plchangesposid' => 'fetch_changes',
64
+ 'prio' => 'fetch_nothing',
65
+ 'prioid' => 'fetch_nothing',
66
+ 'rangeid' => 'fetch_nothing',
67
+ 'shuffle' => 'fetch_nothing',
68
+ 'swap' => 'fetch_nothing',
69
+ 'swapid' => 'fetch_nothing',
70
+ # Stored Playlist Commands
71
+ 'listplaylist' => 'fetch_list',
72
+ 'listplaylistinfo' => 'fetch_songs',
73
+ 'listplaylists' => 'fetch_playlists',
74
+ 'load' => 'fetch_nothing',
75
+ 'playlistadd' => 'fetch_nothing',
76
+ 'playlistclear' => 'fetch_nothing',
77
+ 'playlistdelete' => 'fetch_nothing',
78
+ 'playlistmove' => 'fetch_nothing',
79
+ 'rename' => 'fetch_nothing',
80
+ 'rm' => 'fetch_nothing',
81
+ 'save' => 'fetch_nothing',
82
+ # Database Commands
83
+ 'count' => 'fetch_object',
84
+ 'find' => 'fetch_songs',
85
+ 'findadd' => 'fetch_nothing',
86
+ 'list' => 'fetch_list',
87
+ 'listall' => 'fetch_database',
88
+ 'listallinfo' => 'fetch_database',
89
+ 'listfiles' => 'fetch_database',
90
+ 'lsinfo' => 'fetch_database',
91
+ 'search' => 'fetch_songs',
92
+ 'searchadd' => 'fetch_nothing',
93
+ 'searchaddp1' => 'fetch_nothing',
94
+ 'update' => 'fetch_item',
95
+ 'rescan' => 'fetch_item',
96
+ 'readcomments' => 'fetch_object',
97
+ # Mounts and neighbors
98
+ 'mount' => 'fetch_nothing',
99
+ 'unmount' => 'fetch_nothing',
100
+ 'listmounts' => 'fetch_mounts',
101
+ 'listneighbors' => 'fetch_neighbors',
102
+ # Sticker Commands
103
+ 'sticker get' => 'fetch_sticker',
104
+ 'sticker set' => 'fetch_nothing',
105
+ 'sticker delete' => 'fetch_nothing',
106
+ 'sticker list' => 'fetch_stickers',
107
+ 'sticker find' => 'fetch_songs',
108
+ # Connection Commands
109
+ 'close' => '',
110
+ 'kill' => '',
111
+ 'password' => 'fetch_nothing',
112
+ 'ping' => 'fetch_nothing',
113
+ # Audio Output Commands
114
+ 'disableoutput' => 'fetch_nothing',
115
+ 'enableoutput' => 'fetch_nothing',
116
+ 'outputs' => 'fetch_outputs',
117
+ 'toggleoutput' => 'fetch_nothing',
118
+ # Reflection Commands
119
+ 'config' => 'fetch_item',
120
+ 'commands' => 'fetch_list',
121
+ 'notcommands' => 'fetch_list',
122
+ 'tagtypes' => 'fetch_list',
123
+ 'urlhandlers' => 'fetch_list',
124
+ 'decoders' => 'fetch_plugins',
125
+ # Client To Client
126
+ 'subscribe' => 'fetch_nothing',
127
+ 'unsubscribe' => 'fetch_nothing',
128
+ 'channels' => 'fetch_list',
129
+ 'readmessages' => 'fetch_messages',
130
+ 'sendmessage' => 'fetch_nothing'
131
+ }.freeze
132
+
133
+ # The `MPD::Client` is used for interactions with a MPD server.
134
+ #
135
+ # Example:
136
+ #
137
+ # ```ruby
138
+ # require 'mpd_client'
139
+ # require 'logger'
140
+ #
141
+ # client = MPD::Client.new
142
+ # client.log = Logger.new($stderr)
143
+ # client.connect('/var/run/mpd/socket')
144
+ # ```
145
+ class Client
146
+ attr_reader :mpd_version
147
+
148
+ class << self
149
+ # Default logger for all `MPD::Client`` instances
150
+ #
151
+ # ```ruby
152
+ # MPD::Client.log = Logger.new($stderr)
153
+ # ```
154
+ attr_accessor :log
155
+
156
+ def connect(host = 'localhost', port = 6600, timeout: nil)
157
+ client = MPD::Client.new
158
+ client.connect(host, port, timeout: timeout)
159
+
160
+ client
161
+ end
162
+
163
+ def add_command(name, retval)
164
+ escaped_name = name.tr(' ', '_')
165
+
166
+ define_method escaped_name.to_sym do |*args|
167
+ ensure_connected
168
+
169
+ execute(name, *args, retval)
170
+ end
171
+ end
172
+
173
+ def remove_command(name)
174
+ raise "Can't remove not existent '#{name}' command" unless method_defined? name.to_sym
175
+
176
+ remove_method name.to_sym
177
+ end
178
+ end
179
+
180
+ def initialize
181
+ @mutex = Mutex.new
182
+ reset
183
+ end
184
+
185
+ def connect(host = 'localhost', port = 6600, timeout: nil)
186
+ @host = host
187
+ @port = port
188
+ @timeout = timeout
189
+
190
+ reconnect
191
+ end
192
+
193
+ def reconnect
194
+ log&.info("MPD (re)connect #{@host}, #{@port}")
195
+
196
+ @socket =
197
+ Timeout.timeout(@timeout) do
198
+ if @host.start_with?('/')
199
+ UNIXSocket.new(@host)
200
+ else
201
+ TCPSocket.new(@host, @port)
202
+ end
203
+ end
204
+
205
+ hello
206
+ @connected = true
207
+ end
208
+
209
+ def disconnect
210
+ log&.info('MPD disconnect')
211
+ begin
212
+ Timeout.timeout(@timeout) do
213
+ @socket.close
214
+ end
215
+ rescue Timeout::Error
216
+ @socket = nil
217
+ end
218
+ reset
219
+ end
220
+
221
+ def reset
222
+ @mpd_version = nil
223
+ @command_list = nil
224
+ @socket = nil
225
+ @log = nil
226
+ @connected = false
227
+ end
228
+
229
+ def connected?
230
+ @connected
231
+ end
232
+
233
+ # https://www.musicpd.org/doc/protocol/command_lists.html
234
+ def command_list_ok_begin
235
+ raise 'Already in command list' unless @command_list.nil?
236
+
237
+ write_command('command_list_ok_begin')
238
+
239
+ @command_list = []
240
+ end
241
+
242
+ def command_list_end
243
+ raise 'Not in command list' if @command_list.nil?
244
+
245
+ write_command('command_list_end')
246
+
247
+ fetch_command_list
248
+ end
249
+
250
+ # The current logger. If no logger has been set MPD::Client.log is used
251
+ def log
252
+ @log || MPD::Client.log
253
+ end
254
+
255
+ # Sets the +logger+ used by this instance of MPD::Client
256
+ attr_writer :log
257
+
258
+ def albumart(uri)
259
+ fetch_binary(StringIO.new, 0, 'albumart', uri)
260
+ end
261
+
262
+ def readpicture(uri)
263
+ fetch_binary(StringIO.new, 0, 'readpicture', uri)
264
+ end
265
+
266
+ private
267
+
268
+ def ensure_connected
269
+ raise 'Please connect to MPD server' unless connected?
270
+ end
271
+
272
+ def execute(command, *args, retval)
273
+ @mutex.synchronize do
274
+ write_command(command, *args)
275
+
276
+ if @command_list.nil?
277
+ eval retval
278
+ else
279
+ @command_list << retval
280
+ end
281
+ end
282
+ end
283
+
284
+ def write_line(line)
285
+ begin
286
+ Timeout.timeout(@timeout) do
287
+ @socket.puts line
288
+ end
289
+ rescue Errno::EPIPE, Timeout::Error
290
+ reconnect
291
+ Timeout.timeout(@timeout) do
292
+ @socket.puts line
293
+ end
294
+ end
295
+
296
+ Timeout.timeout(@timeout) do
297
+ @socket.flush
298
+ end
299
+ end
300
+
301
+ def write_command(command, *args)
302
+ parts = [command]
303
+
304
+ args.each do |arg|
305
+ line =
306
+ if arg.is_a?(Array)
307
+ arg.size == 1 ? "\"#{arg[0].to_i}:\"" : "\"#{arg[0].to_i}:#{arg[1].to_i}\""
308
+ else
309
+ "\"#{escape(arg)}\""
310
+ end
311
+
312
+ parts << line
313
+ end
314
+
315
+ # log.debug("Calling MPD: #{command}#{args}") if log
316
+ log&.debug("Calling MPD: #{parts.join(' ')}")
317
+ write_line(parts.join(' '))
318
+ end
319
+
320
+ def read_line
321
+ line = Timeout.timeout(@timeout) do
322
+ @socket.gets
323
+ end
324
+
325
+ raise 'Connection lost while reading line' unless line.end_with?("\n")
326
+
327
+ if line.start_with?(ERROR_PREFIX)
328
+ error = line[/#{ERROR_PREFIX}(.*)/, 1].strip
329
+ raise error
330
+ end
331
+
332
+ if !@command_list.nil?
333
+ return if line == NEXT
334
+ raise "Got unexpected '#{SUCCESS}'" if line == SUCCESS
335
+ elsif line == SUCCESS
336
+ return
337
+ end
338
+
339
+ line
340
+ end
341
+
342
+ def read_pair
343
+ line = read_line
344
+
345
+ return if line.nil?
346
+
347
+ line.split(': ', 2)
348
+ end
349
+
350
+ def read_pairs
351
+ result = []
352
+
353
+ pair = read_pair
354
+
355
+ while pair
356
+ result << pair
357
+ pair = read_pair
358
+ end
359
+
360
+ result
361
+ end
362
+
363
+ def fetch_item
364
+ pairs = read_pairs
365
+
366
+ return nil if pairs.size != 1
367
+
368
+ pairs[0][1]
369
+ end
370
+
371
+ def fetch_nothing
372
+ line = read_line
373
+
374
+ raise "Got unexpected value: #{line}" unless line.nil?
375
+ end
376
+
377
+ def fetch_list
378
+ result = []
379
+ seen = nil
380
+
381
+ read_pairs.each do |key, value|
382
+ value = value.chomp.force_encoding('utf-8')
383
+
384
+ if key != seen
385
+ raise "Expected key '#{seen}', got '#{key}'" unless seen.nil?
386
+
387
+ seen = key
388
+ end
389
+
390
+ result << value
391
+ end
392
+
393
+ result
394
+ end
395
+
396
+ def fetch_objects(delimeters = [])
397
+ result = []
398
+ obj = {}
399
+
400
+ read_pairs.each do |key, value|
401
+ key = key.downcase
402
+ value = value.chomp.force_encoding('utf-8')
403
+
404
+ if delimeters.include?(key)
405
+ result << obj unless obj.empty?
406
+ obj = {}
407
+ elsif obj.include?(key)
408
+ obj[key] << value
409
+ end
410
+
411
+ obj[key] = value
412
+ end
413
+
414
+ result << obj unless obj.empty?
415
+
416
+ result
417
+ end
418
+
419
+ def fetch_object
420
+ objs = fetch_objects
421
+
422
+ objs ? objs[0] : {}
423
+ end
424
+
425
+ def fetch_binary(io = StringIO.new, offset = 0, *args)
426
+ data = {}
427
+
428
+ @mutex.synchronize do
429
+ write_command(*args, offset)
430
+
431
+ binary = false
432
+
433
+ read_pairs.each do |item|
434
+ if binary
435
+ io << item.join(': ')
436
+ next
437
+ end
438
+
439
+ key = item[0]
440
+ value = item[1].chomp
441
+
442
+ binary = (key == 'binary')
443
+
444
+ data[key] = value
445
+ end
446
+ end
447
+
448
+ size = data['size'].to_i
449
+ binary = data['binary'].to_i
450
+
451
+ next_offset = offset + binary
452
+
453
+ return [data, io] if next_offset >= size
454
+
455
+ io.seek(-1, IO::SEEK_CUR)
456
+
457
+ fetch_binary(io, next_offset, *args)
458
+ end
459
+
460
+ def fetch_changes
461
+ fetch_objects(['cpos'])
462
+ end
463
+
464
+ def fetch_songs
465
+ fetch_objects(['file'])
466
+ end
467
+
468
+ def fetch_mounts
469
+ fetch_objects(['mount'])
470
+ end
471
+
472
+ def fetch_neighbors
473
+ fetch_objects(['neighbor'])
474
+ end
475
+
476
+ def fetch_messages
477
+ fetch_objects('channel')
478
+ end
479
+
480
+ def fetch_outputs
481
+ fetch_objects(['outputid'])
482
+ end
483
+
484
+ def fetch_plugins
485
+ fetch_objects(['plugin'])
486
+ end
487
+
488
+ def fetch_database
489
+ fetch_objects(%w[file directory playlist])
490
+ end
491
+
492
+ def fetch_playlists
493
+ fetch_objects(['playlist'])
494
+ end
495
+
496
+ def fetch_stickers
497
+ result = []
498
+
499
+ read_pairs.each do |_key, sticker|
500
+ value = sticker.split('=', 2)
501
+ raise "Could now parse sticker: #{sticker}" if value.size < 2
502
+
503
+ result << Hash[*value]
504
+ end
505
+
506
+ result
507
+ end
508
+
509
+ def fetch_sticker
510
+ fetch_stickers[0]
511
+ end
512
+
513
+ def fetch_command_list
514
+ result = []
515
+
516
+ begin
517
+ @command_list.each do |retval|
518
+ result << (eval retval)
519
+ end
520
+ ensure
521
+ @command_list = nil
522
+ end
523
+
524
+ result
525
+ end
526
+
527
+ def hello
528
+ line = Timeout.timeout(@timeout) do
529
+ @socket.gets
530
+ end
531
+
532
+ raise 'Connection lost while reading MPD hello' unless line.end_with?("\n")
533
+
534
+ line.chomp!
535
+
536
+ raise "Got invalid MPD hello: #{line}" unless line.start_with?(HELLO_PREFIX)
537
+
538
+ @mpd_version = line[/#{HELLO_PREFIX}(.*)/, 1]
539
+ end
540
+
541
+ def escape(text)
542
+ text.to_s.gsub('\\', '\\\\').gsub('"', '\\"')
543
+ end
544
+ end
545
+ end
546
+
547
+ MPD::COMMANDS.each_pair do |name, callback|
548
+ MPD::Client.add_command(name, callback)
549
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'mpd_client/version'
6
+
7
+ Gem::Specification.new do |gem|
8
+ gem.authors = ['Anton Maminov']
9
+ gem.email = ['anton.linux@gmail.com']
10
+ gem.description = 'Yet another Ruby MPD client library'
11
+ gem.summary = 'Simple Music Player Daemon library written entirely in Ruby'
12
+ gem.homepage = 'https://github.com/mamantoha/mpd_client'
13
+
14
+ gem.files = `git ls-files`.split($OUTPUT_RECORD_SEPARATOR)
15
+ gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
16
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
17
+ gem.required_ruby_version = '>= 2.6.6'
18
+ gem.name = 'o-mpd_client'
19
+ gem.require_paths = ['lib']
20
+ gem.version = MPD::Client::VERSION
21
+ gem.license = 'MIT'
22
+
23
+ gem.add_development_dependency 'bundler'
24
+ #gem.metadata['rubygems_mfa_required'] = 'true'
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe MPD::Client do
6
+ it 'should have version' do
7
+ expect(MPD::Client::VERSION).to_not be_nil
8
+ end
9
+
10
+ describe '#initialize' do
11
+ it 'should create new client' do
12
+ client = MPD::Client.new
13
+
14
+ expect(client.connected?).to eq(false)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path(__dir__)
4
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
5
+
6
+ require 'mpd_client'
7
+ require 'rspec'
8
+ require 'pry'
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: o-mpd_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Anton Maminov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-10-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: Yet another Ruby MPD client library
28
+ email:
29
+ - anton.linux@gmail.com
30
+ executables:
31
+ - console
32
+ - setup
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - ".gitignore"
37
+ - ".hound.yml"
38
+ - ".rubocop.yml"
39
+ - ".travis.yml"
40
+ - CHANGELOG.md
41
+ - Gemfile
42
+ - LICENSE
43
+ - MPD_COMMANDS.md
44
+ - README.md
45
+ - bin/console
46
+ - bin/setup
47
+ - examples/Gemfile
48
+ - examples/albumart.rb
49
+ - examples/client.rb
50
+ - examples/idle.rb
51
+ - examples/range.rb
52
+ - examples/rangeid.rb
53
+ - examples/search_and_replace_playlist.rb
54
+ - examples/stickers.rb
55
+ - lib/mpd_client.rb
56
+ - lib/mpd_client/version.rb
57
+ - mpd_client.gemspec
58
+ - spec/mpd_client/mpd_client_spec.rb
59
+ - spec/spec_helper.rb
60
+ homepage: https://github.com/mamantoha/mpd_client
61
+ licenses:
62
+ - MIT
63
+ metadata: {}
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 2.6.6
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.3.15
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: Simple Music Player Daemon library written entirely in Ruby
83
+ test_files:
84
+ - spec/mpd_client/mpd_client_spec.rb
85
+ - spec/spec_helper.rb