subcl 1.0.1 → 1.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.
@@ -0,0 +1,14 @@
1
+ class Song < Hash
2
+ def initialize(subsonic, attributes)
3
+ @subsonic = subsonic
4
+ self['type'] = 'song'
5
+ attributes.each do |key, val|
6
+ self[key] = val
7
+ end
8
+ end
9
+
10
+ #returns the streaming url for a song
11
+ def url
12
+ @subsonic.song_url(self['id'])
13
+ end
14
+ end
@@ -0,0 +1,146 @@
1
+
2
+ class Subcl
3
+ attr_accessor :player, :api, :notifier
4
+
5
+ def initialize(options = {})
6
+ @options = {
7
+ :interactive => true,
8
+ :tty => true,
9
+ :insert => false,
10
+ :out_stream => STDOUT,
11
+ :err_stream => STDERR
12
+ }.merge! options
13
+
14
+ @out = @options[:out_stream]
15
+ @err = @options[:err_stream]
16
+
17
+ begin
18
+ @configs = Configs.new
19
+ rescue => e
20
+ @err.puts "Error initializing config"
21
+ @err.puts e.message
22
+ exit 4
23
+ end
24
+
25
+ @player = @options[:mock_player] || Player.new
26
+
27
+ @api = @options[:mock_api] || SubsonicAPI.new(@configs)
28
+
29
+ @notifier = Notify.new @configs[:notify_method]
30
+
31
+ @display = {
32
+ :song => proc { |song|
33
+ @out.puts sprintf "%-20.20s %-20.20s %-20.20s %-4.4s", song[:title], song[:artist], song[:album], song[:year]
34
+ },
35
+ :album => proc { |album|
36
+ @out.puts sprintf "%-30.30s %-30.30s %-4.4s", album[:name], album[:artist], album[:year]
37
+ },
38
+ :artist => proc { |artist|
39
+ @out.puts "#{artist[:name]}"
40
+ },
41
+ :playlist => proc { |playlist|
42
+ @out.puts "#{playlist[:name]} by #{playlist[:owner]}"
43
+ },
44
+ }
45
+
46
+ end
47
+
48
+ def albumart_url(size = nil)
49
+ current = @player.current
50
+ @api.albumart_url(current, size) unless current.empty?
51
+ end
52
+
53
+ def queue(query, type, inArgs = {})
54
+ args = {
55
+ :clear => false, #whether to clear the playlist prior to adding songs
56
+ :play => false, #whether to start the player after adding the songs
57
+ :insert => false #whether to insert the songs after the current instead of the last one
58
+ }
59
+ args.merge! inArgs
60
+
61
+ if @options[:current]
62
+ query = case type
63
+ when :album
64
+ @player.current_song.album
65
+ when :artist
66
+ @player.current_song.artist
67
+ else
68
+ raise ArgumentError, "'current' option can only be used with albums or artists."
69
+ end
70
+ end
71
+
72
+ songs = case type
73
+ when :randomSong
74
+ begin
75
+ @api.random_songs(query)
76
+ rescue ArgumentError
77
+ raise ArgumentError, "random-songs takes an integer as argument"
78
+ end
79
+ else #song, album, artist, playlist
80
+ entities = @api.search(query, type)
81
+ entities = invoke_picker(entities, &@display[type])
82
+ @api.get_songs(entities)
83
+ end
84
+
85
+ no_matches if songs.empty?
86
+
87
+ @player.clearstop if args[:clear]
88
+
89
+ songs.shuffle! if @options[:shuffle]
90
+
91
+ songs.each do |song|
92
+ @player.add(song, args[:insert])
93
+ end
94
+
95
+ @player.play if args[:play]
96
+ end
97
+
98
+ def print(name, type)
99
+ entities = @api.search(name, type)
100
+ no_matches(type) if entities.empty?
101
+ entities.each do |entity|
102
+ @display[type].call(entity)
103
+ end
104
+ end
105
+
106
+ #print an error that no matches were found, then exit with code 2
107
+ def no_matches(what = nil)
108
+ if what
109
+ message = "No matching #{what}"
110
+ else
111
+ message = "No matches"
112
+ end
113
+
114
+ if @options[:tty]
115
+ @err.puts message
116
+ else
117
+ @notifier.notify(message)
118
+ end
119
+ exit 2
120
+ end
121
+
122
+ def testNotify
123
+ @notifier.notify("Hi!")
124
+ end
125
+
126
+ def albumlist
127
+ @api.albumlist.each do |album|
128
+ @display[:album].call(album)
129
+ end
130
+ end
131
+
132
+ #show an interactive picker that lists every element of the array using &display_proc
133
+ #The user can then choose one, many or no of the elements which will be returned as array
134
+ def invoke_picker(array, &display_proc)
135
+ return array if array.length <= 1
136
+ return [array.first] unless @options[:interactive]
137
+ return Picker.new(array).pick(&display_proc)
138
+ end
139
+
140
+ PLAYER_METHODS = %i{play pause toggle stop next previous rewind}
141
+ def method_missing(name, args)
142
+ raise NoMethodError unless PLAYER_METHODS.include? name
143
+ @player.send(name)
144
+ end
145
+
146
+ end
@@ -0,0 +1 @@
1
+ class SubclError < StandardError; end
@@ -0,0 +1,239 @@
1
+ ###
2
+ # This class interfaces with the subsonic API
3
+ # http://www.subsonic.org/pages/api.jsp
4
+ ###
5
+
6
+ require 'net/http'
7
+ require 'rexml/document'
8
+ require 'thread' #Do I need this?
9
+ require 'cgi'
10
+ include REXML
11
+
12
+ class SubsonicAPI
13
+
14
+ REQUIRED_SETTINGS = %i{server username password}
15
+
16
+ def initialize(configs)
17
+ @configs = {
18
+ :appname => 'subcl',
19
+ :app_version => '0.0.4',
20
+ :proto_version => '1.9.0', #subsonic API protocol version
21
+ :max_search_results => 20,
22
+ :random_song_count => 10
23
+ }.merge! configs.to_hash
24
+
25
+ REQUIRED_SETTINGS.each do |setting|
26
+ unless @configs.key? setting
27
+ raise "Missing setting '#{setting}'"
28
+ end
29
+ end
30
+ end
31
+
32
+ #takes a list of albums or artists and returns a list of their songs
33
+ def get_songs(entities)
34
+ entities.collect_concat do |entity|
35
+ case entity[:type]
36
+ when :song
37
+ entity
38
+ when :album
39
+ album_songs(entity[:id])
40
+ when :artist
41
+ artist_songs(entity[:id])
42
+ when :playlist
43
+ playlist_songs(entity[:id])
44
+ else
45
+ raise "Cannot get songs for '#{entity[:type]}'"
46
+ end
47
+ end
48
+ end
49
+
50
+ #returns an array of songs for the given album id
51
+ def album_songs(id)
52
+ doc = query('getAlbum.view', {:id => id})
53
+ doc.elements.collect('subsonic-response/album/song') do |song|
54
+ decorate_song(song.attributes)
55
+ end
56
+ end
57
+
58
+ #returns an array of songs for the given artist id
59
+ def artist_songs(id)
60
+ doc = query('getArtist.view', {:id => id})
61
+ doc.elements.inject('subsonic-response/artist/album', []) do |memo, album|
62
+ memo += album_songs(album.attributes['id'])
63
+ end
64
+ end
65
+
66
+ #returns all songs from playlist(s) matching the name
67
+ def playlist_songs(id)
68
+ doc = query('getPlaylist.view', {:id => id})
69
+ doc.elements.collect('subsonic-response/playlist/entry') do |entry|
70
+ decorate_song(entry.attributes)
71
+ end
72
+ end
73
+
74
+ #returns all playlists
75
+ def all_playlists
76
+ doc = query('getPlaylists.view')
77
+ doc.elements.collect('subsonic-response/playlists/playlist') do |playlist|
78
+ {
79
+ :id => playlist.attributes['id'],
80
+ :name => playlist.attributes['name'],
81
+ :owner => playlist.attributes['owner'],
82
+ :type => :playlist
83
+ }
84
+ end
85
+ end
86
+
87
+
88
+ #returns all playlists matching name
89
+ #subsonic features no mechanism to search by playlist name, so this method retrieves
90
+ #all playlists and and filters them locally. This might become problematic when the server
91
+ #has a huge amount of playlists
92
+ def get_playlists(name)
93
+ name.downcase!
94
+ all_playlists().select do |playlist|
95
+ playlist[:name].downcase.include? name
96
+ end
97
+ end
98
+
99
+ #returns a list of albums from the specified type
100
+ #http://www.subsonic.org/pages/api.jsp#getAlbumList2
101
+ def albumlist(type = :random)
102
+ #TODO might want to add validation for the type here
103
+ doc = query('getAlbumList2.view', {:type => type})
104
+ doc.elements.collect('subsonic-response/albumList2/album') do |album|
105
+ album = album.attributes
106
+ album = Hash[album.collect { |key,val| [key.to_sym, val] }]
107
+ album[:type] = :album
108
+ album
109
+ end
110
+ end
111
+
112
+ def random_songs(count = @configs[:random_song_count])
113
+ #throws an exception if its not parseable to an int
114
+ count = Integer(count)
115
+
116
+ doc = query('getRandomSongs.view', {:size => count})
117
+ doc.elements.collect('subsonic-response/randomSongs/song') do |song|
118
+ decorate_song(song.attributes)
119
+ end
120
+ end
121
+
122
+ #takes the attributes of a song tag from the xml and applies the
123
+ #:type and :stream_url attribute
124
+ def decorate_song(attributes)
125
+ attributes = Hash[attributes.collect {|key, val| [key.to_sym, val]}]
126
+ attributes[:type] = :song
127
+ attributes[:stream_url] = stream_url(attributes[:id])
128
+ attributes
129
+ end
130
+
131
+ def search(query, type)
132
+ params = {
133
+ :query => query,
134
+ :songCount => 0,
135
+ :albumCount => 0,
136
+ :artistCount => 0,
137
+ }
138
+
139
+ max = @configs[:max_search_results]
140
+ case type
141
+ when :artist
142
+ params[:artistCount] = max
143
+ when :album
144
+ params[:albumCount] = max
145
+ when :song
146
+ params[:songCount] = max
147
+ when :playlist
148
+ return get_playlists(query)
149
+ when :any
150
+ #XXX or do we now use max/3 for each?
151
+ params[:songCount] = max
152
+ params[:albumCount] = max
153
+ params[:artistCount] = max
154
+ else
155
+ raise "Cannot search for type '#{type}'"
156
+ end
157
+
158
+ doc = query('search3.view', params)
159
+
160
+ %i{artist album song}.collect_concat do |entity_type|
161
+ doc.elements.collect("subsonic-response/searchResult3/#{entity_type}") do |entity|
162
+ entity = Hash[entity.attributes.collect{ |key, val| [key.to_sym, val]}]
163
+ entity[:type] = entity_type
164
+ if entity_type == :song
165
+ entity[:stream_url] = stream_url(entity[:id])
166
+ end
167
+ entity
168
+ end
169
+ end
170
+ end
171
+
172
+ def query(method, params = {})
173
+ uri = build_url(method, params)
174
+ LOGGER.debug { "query: #{uri} (basic auth sent per HTTP header)" }
175
+
176
+ req = Net::HTTP::Get.new(uri.request_uri)
177
+ req.basic_auth(@configs[:username], @configs[:password])
178
+ res = Net::HTTP.start(uri.hostname, uri.port) do |http|
179
+ http.request(req)
180
+ end
181
+
182
+ doc = Document.new(res.body)
183
+
184
+ LOGGER.debug { "response: " + doc.to_s }
185
+
186
+ #handle error response
187
+ doc.elements.each('subsonic-response/error') do |error|
188
+ raise SubclError, "#{error.attributes["message"]} (#{error.attributes["code"]})"
189
+ end
190
+
191
+ #handle http error
192
+ case res.code
193
+ when '200'
194
+ return doc
195
+ else
196
+ msg = case res.code
197
+ when '401'
198
+ "HTTP 401. Might be an incorrect username/password"
199
+ else
200
+ "HTTP #{res.code}"
201
+ end
202
+ raise SubclError, msg
203
+ end
204
+ end
205
+
206
+ def build_url(method, params)
207
+ params[:v] = @configs[:proto_version]
208
+ params[:c] = @configs[:appname]
209
+ query = params.collect {|k,v| "#{k}=#{URI.escape(v.to_s)}"}.join('&')
210
+
211
+ URI("#{@configs[:server]}/rest/#{method}?#{query}")
212
+ end
213
+
214
+ #adds the basic auth parameters from the config to the URI
215
+ def add_basic_auth(uri)
216
+ uri.user = @configs[:username]
217
+ uri.password = @configs[:password]
218
+ return uri
219
+ end
220
+
221
+ #returns the streaming URL for the song, including basic auth
222
+ def stream_url(songid)
223
+ raise ArgumentError, "no songid!" unless songid
224
+ uri = build_url('stream.view', {:id => songid})
225
+ add_basic_auth(uri)
226
+ end
227
+
228
+ #returns the albumart URL for the song
229
+ def albumart_url(streamUrl, size = nil)
230
+ raise ArgumentError if streamUrl.empty?
231
+ id = CGI.parse(URI.parse(streamUrl).query)['id'][0]
232
+ params = {:id => id};
233
+ params[:size] = size unless size.nil?
234
+ add_basic_auth(
235
+ build_url('getCoverArt.view', params)
236
+ )
237
+ end
238
+
239
+ end
metadata CHANGED
@@ -1,55 +1,90 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: subcl
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
5
- prerelease:
4
+ version: 1.1.0
6
5
  platform: ruby
7
6
  authors:
8
7
  - Daniel Latzer
9
8
  autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2013-12-03 00:00:00.000000000 Z
13
- dependencies: []
14
- description: ! ' This is a commandline client for the subsonic music server (www.subsonic.org).
15
- It relies on mpd and mpc. '
11
+ date: 2014-02-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 2.14.1
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 2.14.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: ruby-mpd
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '='
32
+ - !ruby/object:Gem::Version
33
+ version: 0.3.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '='
39
+ - !ruby/object:Gem::Version
40
+ version: 0.3.1
41
+ description: " This is a commandline client for the subsonic music server (www.subsonic.org)
42
+ relying on mpd for playback. It supports searching for songs, albums, etc on the
43
+ commandline and adding them to mpds playlist. It also brings some commands to control
44
+ the playback."
16
45
  email: latzer.daniel@gmail.com
17
46
  executables:
18
47
  - subcl
48
+ - subcl-api
19
49
  extensions: []
20
50
  extra_rdoc_files: []
21
51
  files:
22
- - lib/Picker.rb
23
- - lib/Configs.rb
24
- - lib/Subcl.rb
25
- - lib/Subsonic.rb
26
- - lib/Mpc.rb
27
- - lib/Notify.rb
28
- - lib/Song.rb
29
52
  - bin/subcl
53
+ - bin/subcl-api
54
+ - lib/subcl.rb
55
+ - lib/subcl/configs.rb
56
+ - lib/subcl/notify.rb
57
+ - lib/subcl/picker.rb
58
+ - lib/subcl/player.rb
59
+ - lib/subcl/runner.rb
60
+ - lib/subcl/song.rb
61
+ - lib/subcl/subcl.rb
62
+ - lib/subcl/subcl_error.rb
63
+ - lib/subcl/subsonic_api.rb
30
64
  - share/icon.png
31
65
  homepage: https://github.com/Tourniquet/subcl
32
- licenses: []
66
+ licenses:
67
+ - MIT
68
+ metadata: {}
33
69
  post_install_message:
34
70
  rdoc_options: []
35
71
  require_paths:
36
72
  - lib
37
73
  required_ruby_version: !ruby/object:Gem::Requirement
38
- none: false
39
74
  requirements:
40
- - - ! '>='
75
+ - - ">="
41
76
  - !ruby/object:Gem::Version
42
- version: 1.9.2
77
+ version: 2.0.0
43
78
  required_rubygems_version: !ruby/object:Gem::Requirement
44
- none: false
45
79
  requirements:
46
- - - ! '>='
80
+ - - ">="
47
81
  - !ruby/object:Gem::Version
48
82
  version: '0'
49
- requirements: []
83
+ requirements:
84
+ - mpd
50
85
  rubyforge_project:
51
- rubygems_version: 1.8.23
86
+ rubygems_version: 2.2.2
52
87
  signing_key:
53
- specification_version: 3
88
+ specification_version: 4
54
89
  summary: A commandline client for the subsonic music server
55
90
  test_files: []