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.
- checksums.yaml +7 -0
- data/bin/subcl +4 -124
- data/bin/subcl-api +22 -0
- data/lib/subcl.rb +14 -0
- data/lib/subcl/configs.rb +61 -0
- data/lib/subcl/notify.rb +66 -0
- data/lib/subcl/picker.rb +63 -0
- data/lib/subcl/player.rb +54 -0
- data/lib/subcl/runner.rb +154 -0
- data/lib/subcl/song.rb +14 -0
- data/lib/subcl/subcl.rb +146 -0
- data/lib/subcl/subcl_error.rb +1 -0
- data/lib/subcl/subsonic_api.rb +239 -0
- metadata +57 -22
- data/lib/Configs.rb +0 -67
- data/lib/Mpc.rb +0 -68
- data/lib/Notify.rb +0 -66
- data/lib/Picker.rb +0 -63
- data/lib/Song.rb +0 -14
- data/lib/Subcl.rb +0 -159
- data/lib/Subsonic.rb +0 -297
data/lib/subcl/song.rb
ADDED
@@ -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
|
data/lib/subcl/subcl.rb
ADDED
@@ -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
|
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:
|
13
|
-
dependencies:
|
14
|
-
|
15
|
-
|
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:
|
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:
|
86
|
+
rubygems_version: 2.2.2
|
52
87
|
signing_key:
|
53
|
-
specification_version:
|
88
|
+
specification_version: 4
|
54
89
|
summary: A commandline client for the subsonic music server
|
55
90
|
test_files: []
|