o-mpd_client 0.2.0.1

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.
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