lastfm-cli 0.1.2-universal-darwin-9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (5) hide show
  1. data/README +8 -0
  2. data/bin/lastfm-cli +4 -0
  3. data/lib/lastfm.rb +465 -0
  4. data/spec/lastfm_spec.rb +135 -0
  5. metadata +54 -0
data/README ADDED
@@ -0,0 +1,8 @@
1
+ lastfm-cli
2
+
3
+ An interactive command-line & telnet interface to a last.fm account and audio streams. lastfm-cli lets users choose radio stations, play songs, skip songs, "love" them or "ban" them. Multiple users can control one last.fm stream (playing through the office or home sound system, for instance) as long as the computer running lastfm-cli is accessible on the local network.
4
+
5
+ lastfm-cli requires an mp3 stream player. Currently, only iTunes is supported, but support for linux players such as MPG123 is under development.
6
+
7
+ author: Daniel Choi, dhchoi@gmail.com
8
+ license: MIT
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'lastfm'
4
+
@@ -0,0 +1,465 @@
1
+ # == Synopsis
2
+ #
3
+ # hello: greets user, demonstrates command line parsing
4
+ #
5
+ # == Usage
6
+ #
7
+ # hello [OPTION] ... DIR
8
+ #
9
+ # -h, --help:
10
+ # show help
11
+ #
12
+ # REQUIRED ARGUMENTS:
13
+ #
14
+ # --username, -u [lastfm username]:
15
+ # a valid last.fm username
16
+ #
17
+ # --password, -u [lastfm password]:
18
+ # a valid last.fm password
19
+ #
20
+ # OPTIONAL ARGUMENTS:
21
+ #
22
+ # --telnetport, -t [port]:
23
+ # the port to allow telnet access to the lastfm-cli on. 8004 by default.
24
+ #
25
+
26
+ #
27
+ # note this protocal version may be outdated soon
28
+ # protocol reference
29
+ # http://code.google.com/p/thelastripper/wiki/LastFM12UnofficialDocumentation
30
+ # http://www.audioscrobbler.net/development/protocol/
31
+ require 'digest/md5'
32
+ require 'uri'
33
+ require 'net/http'
34
+ require 'yaml'
35
+ class LastFM
36
+ Handshake = %Q[http://ws.audioscrobbler.com/radio/handshake.php?version=1.1.1&platform=linux&username=%s&passwordmd5=%s&debug=0&partner=]
37
+ Handshake2 = %Q[http://post.audioscrobbler.com/?hs=true&p=1.2&c=tst&v=1.0&u=%s&t=%s&a=%s]
38
+ Tuner = %Q[http://ws.audioscrobbler.com/radio/adjust.php?session=%s&url=lastfm://%s&lang=en]
39
+ CurrentSongInfo = %Q[http://ws.audioscrobbler.com/radio/np.php?session=%s&debug=0]
40
+ Command = %Q[http://ws.audioscrobbler.com/radio/control.php?session=%s&command=%s&debug=0]
41
+ attr_accessor :session, :stream_url, :username, :station_url
42
+ def initialize(options)
43
+ @username, @password = options[:username], options[:password]
44
+ @md5_password = Digest::MD5.hexdigest(@password)
45
+ handshake
46
+ end
47
+
48
+ def last_response
49
+ @response
50
+ end
51
+
52
+ # version 1.1 protocol
53
+ def handshake
54
+ response = send_request(Handshake % [@username, @md5_password])
55
+ @session = get_property("session", response.body)
56
+ @stream_url= get_property("stream_url", response.body)
57
+ end
58
+
59
+ # version 1.2 protocol
60
+ # Currently, we'll use the old protocol.
61
+ def handshake2
62
+ timestamp = Time.now.to_i
63
+ auth_token = Digest::MD5.hexdigest(@md5_password.to_s + timestamp.to_s)
64
+ response = send_request(Handshake2 % [@username, timestamp, auth_token])
65
+ puts response.body
66
+ @session = response.body.split("\n")[1]
67
+ response
68
+ end
69
+
70
+ # tuner stations:
71
+ # lastfm://user/<yourlastfmusername>/neighbours
72
+ # lastfm://artist/<Artist>/similarartists
73
+ # lastfm://globaltags/<Tag>
74
+ # For this method, just pass in the path part that varies, e.g.
75
+ # artist/u2/similarartists
76
+ def tune(path=nil)
77
+ @station_url = path || @station_url
78
+ @response = send_request(Tuner % [@session, @station_url])
79
+ return get_property("response", @response.body) == "OK" # return false if failed
80
+ end
81
+
82
+ # Info for the current song
83
+ def current_song
84
+ @response = response = send_request(CurrentSongInfo % [@session])
85
+ # puts response.body # for debugging
86
+ streaming = get_property("streaming", response.body) == "true"
87
+ return nil unless streaming
88
+ song = LastFM::Song.new
89
+ LastFM::Song::SongAttributes.each do |attribute|
90
+ value = get_property(attribute.to_s, response.body)
91
+ if value.nil?
92
+ # something went wrong, reset song
93
+ return nil
94
+ end
95
+ song.send(attribute.to_s + "=", value.strip)
96
+ end
97
+ song
98
+ rescue
99
+ nil
100
+ end
101
+
102
+ # NOTE skip doesn't work for some reason, so we will simply restart the stream
103
+ # from the telnet client
104
+ # skip, love, ban
105
+ def action(command)
106
+ request = Command % [@session, command]
107
+ puts request
108
+ @response = send_request(request)
109
+ puts @response.body
110
+ return get_property("response", @response.body) == "OK" # return false if failed
111
+ end
112
+
113
+ # convenience method for parsing the key/value response of the LastFM protocol
114
+ def get_property(key, responsebody)
115
+ re = /^#{key}=(.*)/
116
+ matchdata = re.match(responsebody)
117
+ return unless matchdata
118
+ matchdata[1]
119
+ end
120
+
121
+ def send_request(url)
122
+ uri = URI.parse(url)
123
+ Net::HTTP.start(uri.host, uri.port) {|http| http.get(uri.request_uri)}
124
+ end
125
+ end
126
+
127
+ class LastFM
128
+ class Song
129
+ SongAttributes = [:station, :artist, :artist_url, :track, :track_url, :album, :album_url, :albumcover_small, :albumcover_medium, :albumcover_large, :trackduration]
130
+ attr_accessor *SongAttributes
131
+ end
132
+
133
+ class Client
134
+ attr_accessor :current_song
135
+ def initialize(lastfm, audio_output)
136
+ @lastfm = lastfm
137
+ @audio_output = audio_output
138
+ @output_buffer = []
139
+ end
140
+ def start
141
+ display_current_song
142
+ end
143
+ def parse_command(command, *args)
144
+ if command.nil?
145
+ command = "current song info"
146
+ end
147
+ args = args.join(' ').strip
148
+ puts "request: #{command} #{args}"
149
+
150
+ case command
151
+ when /^h/
152
+ @output_buffer << help
153
+ when /^sk/ #skip
154
+ skip
155
+ @output_buffer << "skipping..."
156
+ when /^st/ #stop
157
+ stop
158
+ @output_buffer << "stopping..."
159
+ when /^pl/ #play
160
+ play
161
+ @output_buffer << "playing..."
162
+ when /^v/
163
+ if args != ''
164
+ @audio_output.volume = args.to_i
165
+ @output_buffer << "setting volume to #{args}"
166
+ else
167
+ @output_buffer << "current volume: #{@audio_output.volume}"
168
+ end
169
+ when /^l/ #"love"
170
+ @lastfm.action("love")
171
+ when /^b/ # "ban"
172
+ @lastfm.action("ban")
173
+ when /^q/ #"quit"
174
+ @audio_output.stop
175
+ exit
176
+ when /^a/ # "artist"
177
+ tune("artist/#{args}/similarartists")
178
+ when /^t/ #"tags" or tags
179
+ tune("globaltags/#{args}")
180
+ when /^r/ # "recommended"
181
+ tune("user/#{@lastfm.username}/recommended/100")
182
+ when /^n/ #"neighbors"
183
+ tune("user/#{@lastfm.username}/neighbours")
184
+ # when "tune"
185
+ # tune(args.first)
186
+ else
187
+ display_current_song
188
+ end
189
+ end
190
+
191
+ def tune(path)
192
+ @lastfm.tune(path=URI.escape(path))
193
+ @output_buffer << "tuning to #{path}"
194
+ sleep 1
195
+ skip
196
+ end
197
+
198
+ def skip
199
+ @audio_output.skip
200
+ sleep 3 # so that current song has a chance to update
201
+ # The last fm skip command via the Command url doesn't work right now
202
+ #@lastfm.action("skip")
203
+ display_current_song
204
+ end
205
+
206
+ def play
207
+ skip
208
+ end
209
+
210
+ def stop
211
+ @audio_output.stop
212
+ end
213
+
214
+ def divider
215
+ "-" * 80
216
+ end
217
+
218
+ def display_current_song
219
+ song = @lastfm.current_song
220
+ if song.nil?
221
+ # use the previous current song
222
+ return "\nNo current song\n" unless @current_song
223
+ else
224
+ @current_song = song
225
+ end
226
+ @output_buffer << "Currently Playing:"
227
+ @output_buffer << divider
228
+ %W{ artist track album station }.each do |x|
229
+ unless song.respond_to?(x)
230
+ @output_buffer = "something is wrong with the last.fm connection. no song data"
231
+ break
232
+ end
233
+ @output_buffer << x + ": " + song.send(x)
234
+ end
235
+ @output_buffer << divider
236
+ @output_buffer
237
+ end
238
+
239
+ def flush_output_buffer
240
+ temp = @output_buffer.dup
241
+ @output_buffer = []
242
+ if temp.class == String
243
+ temp
244
+ else
245
+ temp.join("\n")
246
+ end
247
+ end
248
+
249
+ def prompt
250
+ "Press h for help\nlastfm> "
251
+ end
252
+
253
+ def help
254
+ %Q[Commands: skip
255
+ love
256
+ ban
257
+ recommended
258
+ artist [artist]
259
+ tag [tag]
260
+ tags [tag+tag]
261
+ volume
262
+ volume [1-100]
263
+ stop
264
+ play
265
+ quit
266
+ [return]
267
+
268
+ You can truncate the commands to the fewest possible
269
+ unambiguous letters.
270
+
271
+ LastFM is sometimes unresponsive. In that case,
272
+ try "skip" every few seconds until it works again.
273
+
274
+ lastfm> ]
275
+ end
276
+
277
+ require 'socket'
278
+ class TelnetClient < LastFM::Client
279
+ def initialize(lastfm, audio_output, port)
280
+ @port = port
281
+ super(lastfm, audio_output)
282
+ end
283
+ def start
284
+ server = TCPServer.new('localhost', @port)
285
+ puts
286
+ puts "Starting last.fm telnet server on port #{@port}.
287
+
288
+ Other computers can log in and control the lastfm stream on this
289
+ computer simultaneously.
290
+
291
+ For a better experience, try logging in using rlwrap
292
+ (which provides readline support)."
293
+ display_current_song
294
+ while session=server.accept
295
+ puts "A telnet session has just started."
296
+ Thread.new(session) do |my_session|
297
+ my_session.print prompt
298
+ while req = my_session.gets
299
+ puts "\n"
300
+ puts divider
301
+ puts "TELNET REQUEST"
302
+ req = req.split(" ")
303
+ if req[0] =~ /^q/ # quit
304
+ my_session.close
305
+ break
306
+ end
307
+ command = req.shift
308
+ parse_command(command, req)
309
+ puts divider
310
+ print prompt
311
+ $stdout.flush
312
+ my_session.print flush_output_buffer
313
+
314
+ unless command =~ /^h/
315
+ my_session.print "\n"
316
+ my_session.print prompt
317
+ end
318
+ end
319
+ puts "A telnet session just ended."
320
+ end
321
+ end
322
+ end
323
+ end
324
+ class CommandLineClient < LastFM::Client
325
+ def initialize(lastfm, audio_output)
326
+ super
327
+ end
328
+ def start
329
+ puts
330
+ #puts "starting command line lastfm client"
331
+ print prompt
332
+ $stdout.flush
333
+ while req = gets
334
+ req = req.split(" ")
335
+ puts
336
+ command = req.shift
337
+ parse_command(command, req)
338
+ print flush_output_buffer
339
+ unless command =~ /^h/
340
+ puts "\n"
341
+ print prompt
342
+ end
343
+ $stdout.flush
344
+ end
345
+ #puts "command line session ended"
346
+ end
347
+ end
348
+ end
349
+
350
+ module AudioOutput
351
+ class RAOP
352
+ end
353
+
354
+ class ITunes
355
+ require 'osx/cocoa'
356
+ OSX.require_framework 'ScriptingBridge'
357
+
358
+ def initialize(stream_url)
359
+ @itunes = OSX::SBApplication.applicationWithBundleIdentifier("com.apple.iTunes")
360
+ @stream_url = stream_url
361
+ end
362
+
363
+ def quit
364
+ @itunes.stop
365
+ end
366
+
367
+ def play
368
+ skip
369
+ end
370
+ def stop
371
+ @itunes.stop
372
+ end
373
+ def skip
374
+ # simply restart the stream
375
+ fork do
376
+ %x{open -g -a iTunes.app #{@stream_url} &}
377
+ end
378
+ end
379
+ def start
380
+ fork do
381
+ %x{open -g -a iTunes.app #{@stream_url} &}
382
+ end
383
+ end
384
+ def volume
385
+ @itunes.soundVolume
386
+ end
387
+ def volume=(value)
388
+ @itunes.soundVolume = value
389
+ end
390
+ end
391
+
392
+ # Code Incomplete
393
+ class MPG123
394
+ def initialize(stream_url)
395
+ @stream_url = stream_url
396
+ end
397
+ def start
398
+ # TODO put this on a separate thread
399
+ command = "mpg123 #{@stream_url}"
400
+ puts command
401
+ @pipe = IO.popen(command)
402
+ @pid = @pipe.pid
403
+ puts "PID: #{@pid}"
404
+ end
405
+ # no volume control facility
406
+ end
407
+ end
408
+ end
409
+
410
+ def credits
411
+ message = "lastfm-cli : a last.fm ruby client. by daniel choi / betahouse.org / Apr 2008"
412
+ puts "-" * message.length
413
+ puts message
414
+ puts "-" * message.length
415
+ end
416
+
417
+ require 'getoptlong'
418
+ require 'rdoc/usage'
419
+ def parse_options
420
+ opts = GetoptLong.new(
421
+ [ "--username", "-u", GetoptLong::REQUIRED_ARGUMENT],
422
+ [ "--password", "-p", GetoptLong::REQUIRED_ARGUMENT],
423
+ [ "--help", "-h", GetoptLong::OPTIONAL_ARGUMENT],
424
+ [ "--telnetport", "-t", GetoptLong::OPTIONAL_ARGUMENT]
425
+ )
426
+
427
+ username, password, telnetport = nil, nil, 8004
428
+ print "LastFM Username: "
429
+ username = STDIN.gets.chomp
430
+ print "LastFM Password (will remain hidden): "
431
+ `stty -echo`
432
+ password = STDIN.gets.chomp ensure `stty echo`
433
+ puts
434
+ print "Telnet port (default is 8004): "
435
+ telnetport = STDIN.gets.chomp
436
+ telnetport = if telnetport == ""
437
+ 8004
438
+ else
439
+ telnetport.to_i
440
+ end
441
+
442
+ if username.nil? || password.nil?
443
+ puts
444
+ puts "* MISSING SOME REQUIRED ARGUMENTS *"
445
+
446
+ exit
447
+ end
448
+ lastfm = LastFM.new(:username => username, :password => password)
449
+ audio_output = LastFM::AudioOutput::ITunes.new(lastfm.stream_url)
450
+ audio_output.start
451
+ puts "starting iTunes radio..."
452
+ puts "Please be patient.\nIt may take some time to connect to the lastfm stream."
453
+ Thread.new do
454
+ client = LastFM::Client::TelnetClient.new(lastfm, audio_output, telnetport)
455
+ #client = LastFM::Client::TelnetClient.new(lastfm, audio_output, 8004)
456
+ client.start
457
+ end
458
+ client2 = LastFM::Client::CommandLineClient.new(lastfm, audio_output)
459
+ client2.start
460
+
461
+ end
462
+ credits # print credits
463
+ parse_options
464
+
465
+
@@ -0,0 +1,135 @@
1
+ require '../lib/lastfm'
2
+
3
+ PASSWORD = "blah"
4
+ USERNAME = "blach"
5
+ describe "LastFM (handshaking)" do
6
+ before do
7
+ Net::HTTP.stub!(:start).and_return(mock_handshake_response)
8
+ @lastfm = LastFM.new(:username => USERNAME, :password => PASSWORD)
9
+ end
10
+
11
+ it "should expose a get_property convenience method to parse the response" do
12
+ @lastfm.get_property("session", mock_handshake_response.body).should ==
13
+ "6ad90a86a8b5b126e94d0c8850b8c769"
14
+ @lastfm.get_property("stream_url", mock_handshake_response.body).should ==
15
+ "http://87.117.229.85:80/last.mp3?Session=6ad90a86a8b5b126e94d0c8850b8c769"
16
+ end
17
+
18
+ it "should set @session after performing a handshake" do
19
+ @lastfm.session.should == "6ad90a86a8b5b126e94d0c8850b8c769"
20
+ @lastfm.stream_url.should == "http://87.117.229.85:80/last.mp3?Session=6ad90a86a8b5b126e94d0c8850b8c769"
21
+ end
22
+ end
23
+
24
+ describe "LastFM (handshaking with protocol 1.2)" do
25
+ before do
26
+ pending
27
+ Net::HTTP.stub!(:start).and_return(mock_handshake2_response)
28
+ @lastfm = LastFM.new(:username => USERNAME, :password => PASSWORD)
29
+ end
30
+
31
+ it "should set @session after performing a handshake" do
32
+ pending("stick with 1.1 for now")
33
+ @lastfm.handshake2
34
+ @lastfm.session.should == "78c03e8103ae6906a180771f6dc65df6"
35
+ end
36
+ end
37
+
38
+ describe "LastFM (tuning)" do
39
+ before do
40
+ Net::HTTP.stub!(:start).and_return(mock_handshake_response)
41
+ @lastfm = LastFM.new(:username => USERNAME, :password => PASSWORD)
42
+ end
43
+
44
+ it "should tune the lastfm station" do
45
+ Net::HTTP.stub!(:start).and_return(mock_tuner_response)
46
+ @lastfm.tune("globaltags/indie")
47
+ @lastfm.get_property("response", @lastfm.last_response.body).should == "OK"
48
+ @lastfm.get_property("stationname", @lastfm.last_response.body).should == " indie Tag Radio"
49
+ end
50
+ end
51
+
52
+ describe "LastFM (skipping a song)" do
53
+ before do
54
+ Net::HTTP.stub!(:start).and_return(mock_handshake_response)
55
+ @lastfm = LastFM.new(:username => USERNAME, :password => PASSWORD)
56
+ end
57
+
58
+ it "should tune the lastfm station" do
59
+ Net::HTTP.stub!(:start).and_return(mock_tuner_response)
60
+ @lastfm.action("skip").should be_true
61
+ end
62
+ end
63
+
64
+ describe "LastFM (getting current song info)" do
65
+ before do
66
+ Net::HTTP.stub!(:start).and_return(mock_handshake_response)
67
+ @lastfm = LastFM.new(:username => USERNAME, :password => PASSWORD)
68
+ end
69
+
70
+ it "should get info for the current song" do
71
+ Net::HTTP.stub!(:start).and_return(mock_current_song_info)
72
+ song = @lastfm.current_song
73
+ song.artist.should == "Metric"
74
+ song.station.should == "indie Tag Radio"
75
+ end
76
+ end
77
+
78
+ def mock_handshake_response
79
+ response = mock("response")
80
+ response.stub!(:body).and_return(<<-resp
81
+ session=6ad90a86a8b5b126e94d0c8850b8c769
82
+ stream_url=http://87.117.229.85:80/last.mp3?Session=6ad90a86a8b5b126e94d0c8850b8c769
83
+ subscriber=0
84
+ framehack=0..
85
+ base_url=ws.audioscrobbler.com
86
+ base_path=/radio
87
+ info_message=
88
+ fingerprint_upload_url=http://ws.audioscrobbler.com/fingerprint/upload.php
89
+ permit_bootstrap=0
90
+ resp
91
+ )
92
+ response
93
+ end
94
+
95
+ def mock_handshake2_response
96
+ response = mock("response")
97
+ response.stub!(:body).and_return "OK
98
+ 78c03e8103ae6906a180771f6dc65df6
99
+ http://post.audioscrobbler.com:80/np_1.2
100
+ http://87.117.229.205:80/protocol_1.2"
101
+ response
102
+ end
103
+
104
+ def mock_tuner_response
105
+ response = mock("response")
106
+ response.stub!(:body).and_return "response=OK
107
+ url=lastfm://globaltags/indie
108
+ stationname= indie Tag Radio
109
+ discovery=true"
110
+ response
111
+ end
112
+
113
+ def mock_current_song_info
114
+ response = mock("response")
115
+ response.stub!(:body).and_return "price=
116
+ shopname=
117
+ clickthrulink=
118
+ streaming=true
119
+ discovery=0
120
+ station= indie Tag Radio
121
+ artist=Metric
122
+ artist_url=http://www.last.fm/music/Metric
123
+ track=Hustle Rose
124
+ track_url=http://www.last.fm/music/Metric/_/Hustle+Rose
125
+ album=Old World Underground, Where Are You Now
126
+ album_url=http://www.last.fm/music/Metric/Old+World+Underground%2C+Where+Are+You+Now
127
+ albumcover_small=http://cdn.last.fm/coverart/130x130/1428771.jpg
128
+ albumcover_medium=http://cdn.last.fm/coverart/130x130/1428771.jpg
129
+ albumcover_large=http://cdn.last.fm/coverart/130x130/1428771.jpg
130
+ trackduration=332
131
+ radiomode=1
132
+ recordtoprofile="
133
+ response
134
+ end
135
+
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lastfm-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: universal-darwin-9
6
+ authors:
7
+ - Daniel Choi
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-04-21 00:00:00 -04:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: lastfm-cli lets users choose radio stations, play songs, skip songs, "love" them or "ban" them. Multiple users can control one last.fm stream (playing through the office or home sound system, for instance) as long as the computer running lastfm-cli is accessible on the local network. lastfm-cli requires an mp3 stream player. Currently, only iTunes is supported, but support for linux players such as MPG123 is under development.
17
+ email: dhchoi@gmail.com
18
+ executables:
19
+ - lastfm-cli
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - README
24
+ files:
25
+ - lib/lastfm.rb
26
+ - README
27
+ has_rdoc: true
28
+ homepage: http://cesareborgia.com/software/lastfm-cli/
29
+ post_install_message:
30
+ rdoc_options: []
31
+
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: "0"
39
+ version:
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: "0"
45
+ version:
46
+ requirements: []
47
+
48
+ rubyforge_project:
49
+ rubygems_version: 1.1.1
50
+ signing_key:
51
+ specification_version: 2
52
+ summary: An interactive command-line and telnet interface to a last.fm account and audio streams.
53
+ test_files:
54
+ - spec/lastfm_spec.rb