audioscrobbler 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/MANIFEST ADDED
@@ -0,0 +1,9 @@
1
+ audioscrobbler.gemspec
2
+ COPYING
3
+ INSTALL
4
+ lib/audioscrobbler.rb
5
+ MANIFEST
6
+ README
7
+ setup.rb
8
+ test/test_audioscrobbler.rb
9
+ test/test_queue.rb
data/README ADDED
@@ -0,0 +1,6 @@
1
+ This package contains an Audioscrobbler class for Ruby that implements the
2
+ Audioscrobbler plugin protocol, used to submit playlist history to Last.fm.
3
+ The INSTALL file contains installation instructions.
4
+
5
+ Daniel Erat <dan-ruby@erat.org>
6
+ http://www.erat.org/ruby/
@@ -0,0 +1,16 @@
1
+ require 'rubygems'
2
+ SPEC = Gem::Specification.new do |s|
3
+ s.name = "audioscrobbler"
4
+ s.version = "0.0.1"
5
+ s.author = "Daniel Erat"
6
+ s.email = "dan-ruby@erat.org"
7
+ s.homepage = "http://www.erat.org/ruby/"
8
+ s.platform = Gem::Platform::RUBY
9
+ s.summary = "Library to submit music playlists to Last.fm"
10
+ candidates = Dir.glob("{*,{lib,test}/*}")
11
+ s.files = candidates.delete_if {|i| i =~ /CVS/ }
12
+ s.require_path = "lib"
13
+ s.autorequire = "audioscrobbler"
14
+ s.test_files = Dir.glob("test/test_{audioscrobbler,queue}.rb")
15
+ s.has_rdoc = true
16
+ end
@@ -0,0 +1,475 @@
1
+ #!/usr/bin/ruby -w
2
+ #
3
+ # = Name
4
+ # Audioscrobbler
5
+ #
6
+ # == Description
7
+ # This file contains an implementation of the Audioscrobbler plugin
8
+ # protocol, used to submit playlist history to Last.fm. The protocol
9
+ # description is located at http://www.audioscrobbler.net/wiki/Protocol1.1 .
10
+ #
11
+ # == Version
12
+ # 0.0.1
13
+ #
14
+ # == Author
15
+ # Daniel Erat <dan-ruby@erat.org>
16
+ #
17
+ # == Copyright
18
+ # Copyright 2005 Daniel Erat
19
+ #
20
+ # == License
21
+ # GNU GPL; see COPYING
22
+ #
23
+ # == Changes
24
+ # 0.0.1 Initial release
25
+
26
+ require "cgi"
27
+ require "md5"
28
+ require "net/http"
29
+ require "thread"
30
+ require "uri"
31
+
32
+ # = Audioscrobbler
33
+ #
34
+ # == Description
35
+ # Queue music tracks as they are played and submit the track information to
36
+ # Last.fm (http://www.last.fm) using the Audioscrobbler plugin protocol
37
+ # (http://www.audioscrobbler.net/).
38
+ #
39
+ # Version 1.1 of the plugin protocol
40
+ # (http://www.audioscrobbler.net/wiki/Protocol1.1) is currently used.
41
+ #
42
+ # == Usage
43
+ # require "audioscrobbler"
44
+ # scrob = Audioscrobbler.new("user", # Audioscrobbler username
45
+ # "pass", # Audioscrobbler password
46
+ # "queue.txt", # file for storing queue
47
+ # )
48
+ #
49
+ # # Replace these with the client ID that's been assigned to your
50
+ # # plugin by the Audioscrobbler folks and your plugin's version number.
51
+ # scrob.client = "tst"
52
+ # scrob.version = "1.0"
53
+ #
54
+ # # If you don't start the submitter, tracks will just pile up in the
55
+ # # submission queue.
56
+ # scrob.start_submitter_thread
57
+ #
58
+ # # Now wait until the Audioscrobbler submission criteria has been met.
59
+ #
60
+ # # And then queue the track for submission:
61
+ # scrob.enqueue("Beach Boys", # artist
62
+ # "God Only Knows", # title
63
+ # 175, # track length, in seconds
64
+ # 1125378558, # track start time
65
+ # "Pet Sounds", # album (optional)
66
+ # "", # MusicBrainzID (optional)
67
+ # )
68
+ class Audioscrobbler
69
+ # Default URI to connect to for the handshake.
70
+ DEFAULT_HANDSHAKE_URI = "http://post.audioscrobbler.com/"
71
+
72
+ # Default minimum interval to wait between successful submissions.
73
+ # This will be overriden if the server supplies an interval.
74
+ DEFAULT_SUBMIT_INTERVAL_SEC = 5
75
+
76
+ # Default plugin name and version to report to the server.
77
+ # You MUST set these to the values that you've registered before
78
+ # you can distribute your plugin to anyone (including beta testers).
79
+ DEFAULT_CLIENT = "tst"
80
+ DEFAULT_VERSION = "1.0"
81
+
82
+ # Maximum number of tracks that will be accepted in a single
83
+ # submission. This is a server-imposed limit.
84
+ MAX_TRACKS_IN_SUBMISSION = 10
85
+
86
+ ##
87
+ # Create a new Audioscrobbler object.
88
+ #
89
+ # @param username Audioscrobbler account username
90
+ # @param password Audioscrobbler account password
91
+ # @param filename file used for on-disk storage of not-yet-submitted
92
+ # tracks
93
+ #
94
+ def initialize(username, password, filename=nil)
95
+ @username = username
96
+ @password = password
97
+ @queue = SubmissionQueue.new(filename)
98
+ @verbose = false
99
+ @client = DEFAULT_CLIENT
100
+ @version = DEFAULT_VERSION
101
+
102
+ @handshake_uri = DEFAULT_HANDSHAKE_URI
103
+ @last_handshake_time = nil
104
+ @handshake_backoff_sec = 0
105
+
106
+ @md5_resp = nil
107
+ @submit_uri = nil
108
+ @submit_interval_sec = DEFAULT_SUBMIT_INTERVAL_SEC
109
+ end
110
+ attr_accessor :username, :password, :verbose
111
+ attr_accessor :client, :version, :handshake_uri
112
+
113
+ ##
114
+ # Update the backoff interval after handshake failure. If we haven't
115
+ # failed yet, we wait a minute; otherwise, we wait twice as long as last
116
+ # time, up to a maximum of two hours.
117
+ #
118
+ # @param message string logged to stderr if verbose logging is enabled
119
+ #
120
+ def handle_handshake_failure(message)
121
+ vlog(message)
122
+ if @handshake_backoff_sec < 60
123
+ @handshake_backoff_sec = 60
124
+ elsif @handshake_backoff_sec < 2 * 60 * 60
125
+ @handshake_backoff_sec *= 2
126
+ else
127
+ @handshake_backoff_sec = 2 * 60 * 60
128
+ end
129
+ end
130
+ private :handle_handshake_failure
131
+
132
+ ##
133
+ # Attempt to handshake with the server.
134
+ # Returns true on success and false on failure.
135
+ # If the connection fails, @handshake_backoff_sec is also updated
136
+ # appropriately. Wait this long before trying to handshake again.
137
+ #
138
+ def do_handshake
139
+ # Sleep before trying again if needed.
140
+ if @last_handshake_time != nil and \
141
+ @last_handshake_time + @handshake_backoff_sec > Time.now
142
+ sleep(@last_handshake_time + @handshake_backoff_sec - Time.now)
143
+ end
144
+
145
+ @last_handshake_time = Time.now
146
+
147
+ args = {
148
+ "hs" => "true",
149
+ "p" => "1.1",
150
+ "c" => @client,
151
+ "v" => @version,
152
+ "u" => @username,
153
+ }
154
+ uri = @handshake_uri + '?' + args.collect {|attr, value|
155
+ CGI.escape(attr.to_s) + '=' + CGI.escape(value.to_s)
156
+ }.join('&')
157
+
158
+ vlog("Beginning handshake with #@handshake_uri")
159
+
160
+ begin
161
+ data = Net::HTTP.get_response(URI.parse(uri)).body
162
+ rescue Exception
163
+ handle_handshake_failure(
164
+ "Read of #@handshake_uri for handshake failed: #{$!}")
165
+ return false
166
+ end
167
+
168
+ # Make sure that we got something back.
169
+ if not data
170
+ handle_handshake_failure(
171
+ "Got empty response from server during handshake")
172
+ return false
173
+ end
174
+
175
+ lines = data.split("\n")
176
+
177
+ # Check whether the server says we can talk to it.
178
+ # INTERVAL is supposed to be returned even on failure, but we don't
179
+ # bother looking at it in this case because we already back off
180
+ # extremely aggressively from handshake failures, and we'll end up
181
+ # getting a new submission interval when we handshake successfully.
182
+ response = lines[0].split[0]
183
+ if response != "UPTODATE" and response != "UPDATE"
184
+ handle_handshake_failure("Got \"#{lines[0]}\" from server during " \
185
+ "handshake (expected UPTODATE or UPDATE)")
186
+ return false
187
+ end
188
+
189
+ # The expected response is something along the lines of:
190
+ # UPTODATE or UPDATE <plugin uri>
191
+ # <md5 challenge>
192
+ # <uri to submit script>
193
+ # INTERVAL <num>
194
+ if lines.length < 3
195
+ handle_handshake_failure(
196
+ "Got #{lines.length} during handshake (expected >=3)")
197
+ return false
198
+ end
199
+
200
+ # Create our response based on the server's challenge and
201
+ # save the submission URI.
202
+ @md5_resp = MD5.hexdigest(MD5.hexdigest(@password) + lines[1])
203
+ @submit_uri = lines[2]
204
+ vlog("Using MD5 response #@md5_resp")
205
+ vlog("Got submission URI #@submit_uri")
206
+
207
+ # Get submission interval.
208
+ if lines.length < 4 or lines[3].split.length != 2 or \
209
+ lines[3].split[0] != "INTERVAL"
210
+ vlog("Didn't get submission interval; using #@submit_interval_sec sec")
211
+ else
212
+ @submit_interval_sec = lines[3].split[0].to_i
213
+ vlog("Got submission interval of #@submit_interval_sec sec")
214
+ end
215
+
216
+ return true
217
+ end
218
+ private :do_handshake
219
+
220
+ ##
221
+ # Start the submitter thread and return.
222
+ #
223
+ def start_submitter_thread
224
+ @submit_thread = Thread.new do
225
+ while true
226
+ # Wait until there are some tracks in the queue.
227
+ tracks = @queue.peek(MAX_TRACKS_IN_SUBMISSION)
228
+
229
+ # Keep trying to handshake until we're successful.
230
+ do_handshake while not @md5_resp
231
+
232
+ # Might as well re-check in case more tracks have shown up
233
+ # during the handshaking.
234
+ tracks = @queue.peek(MAX_TRACKS_IN_SUBMISSION)
235
+ vlog("Submitting #{tracks.length} track(s)")
236
+
237
+ # Construct our argument list.
238
+ args = {
239
+ "u" => @username,
240
+ "s" => @md5_resp,
241
+ }
242
+ for i in 0..tracks.length-1
243
+ args.update({
244
+ "a[#{i}]" => tracks[i].artist,
245
+ "t[#{i}]" => tracks[i].title,
246
+ "b[#{i}]" => tracks[i].album,
247
+ "m[#{i}]" => tracks[i].mbid,
248
+ "l[#{i}]" => tracks[i].length.to_s,
249
+ "i[#{i}]" => Time.at(tracks[i].start_time).utc.
250
+ strftime('%Y-%m-%d %H:%M:%S')
251
+ })
252
+ end
253
+ # Convert it into a single escaped string for the body.
254
+ body = args.collect {|k, v| "#{k}=" + CGI.escape(v.to_s) }.join('&')
255
+
256
+ success = false
257
+
258
+ begin
259
+ uri = URI.parse(@submit_uri)
260
+ data = Net::HTTP.start(uri.host, uri.port) do |http|
261
+ http.post(uri.path, body).body
262
+ end
263
+ rescue Exception
264
+ vlog("Submission failed -- couldn't read #@submit_uri: #{$!}")
265
+ else
266
+ # Check whether the submission was successful.
267
+ lines = data.split("\n")
268
+ if not lines[0]
269
+ vlog("Submission failed -- got empty response")
270
+ elsif lines[0] == "OK"
271
+ vlog("Submission was successful")
272
+ @queue.delete(tracks.length)
273
+ success = true
274
+ elsif lines[0] == "BADAUTH"
275
+ vlog("Submission failed -- authorization failed")
276
+ # Unset the hash so we'll re-handshake.
277
+ @md5_resp = nil
278
+ else
279
+ vlog("Submission failed -- got unknown response \"#{lines[0]}\"")
280
+ end
281
+
282
+ # Use the server-supplied submission interval, or our default
283
+ # if the server didn't supply one.
284
+ if lines.length > 1 and lines[1].split[0] and \
285
+ lines[1].split[0] == "INTERVAL" and lines[1].split[1]
286
+ @submit_interval_sec = lines[1].split[1].to_i
287
+ else
288
+ @submit_interval_sec = DEFAULT_SUBMIT_INTERVAL_SEC
289
+ end
290
+ end
291
+
292
+ # If we failed, do exponential backoff starting at 60 seconds.
293
+ # Better safe than blocked!
294
+ if not success
295
+ if @submit_interval_sec < 60
296
+ @submit_interval_sec = 60
297
+ else
298
+ @submit_interval_sec *= 2
299
+ end
300
+ vlog("Because of failure, backing off to #@submit_interval_sec sec")
301
+ end
302
+
303
+ vlog("Sleeping #@submit_interval_sec sec before checking for " \
304
+ "more tracks")
305
+ sleep(@submit_interval_sec)
306
+ end
307
+ end
308
+ end
309
+
310
+ ##
311
+ # Enqueue a track for submission.
312
+ #
313
+ # @param artist artist name
314
+ # @param title track name
315
+ # @param length track length
316
+ # @param start_time track start time, as UTC unix time
317
+ # @param album album name
318
+ # @param mbid MusicBrainz ID
319
+ #
320
+ def enqueue(artist, title, length, start_time, album="", mbid="")
321
+ @queue.append(artist, title, length, start_time, album, mbid)
322
+ end
323
+
324
+ ##
325
+ # Log a message, along with the current time, to stderr.
326
+ #
327
+ # @param message message to log
328
+ #
329
+ def log(message)
330
+ STDERR.puts(Time.now.strftime("%Y-%m-%d %H:%M:%S") + " " + message.to_s)
331
+ end
332
+ private :log
333
+
334
+ ##
335
+ # Only log if verbose logging is enabled.
336
+ #
337
+ # @param message message to log
338
+ #
339
+ def vlog(message)
340
+ log(message) if @verbose
341
+ end
342
+ private :vlog
343
+
344
+ ##
345
+ # A synchronized, backed-up-to-disk queue holding tracks for submission.
346
+ # The synchronization is only sufficient for a single reader and writer.
347
+ #
348
+ class SubmissionQueue
349
+ ##
350
+ # constructor
351
+ # If a filename is supplied, it will be used to:
352
+ # a) load tracks that were previously played but not submitted
353
+ # b) save the state of the queue for later use in a)
354
+ #
355
+ # @param filename queue filename (optional)
356
+ #
357
+ def initialize(filename=nil)
358
+ @filename = filename
359
+ @queue = Array.new
360
+ @mutex = Mutex.new
361
+ @condvar = ConditionVariable.new
362
+ if @filename and File.exist?(@filename)
363
+ File.open(@filename, "r").each do |line|
364
+ @queue.push(PlayedTrack.deserialize(line.chomp))
365
+ end
366
+ end
367
+ end
368
+
369
+ ##
370
+ # Append a played track to the submission queue.
371
+ #
372
+ # @param artist artist name
373
+ # @param title track name
374
+ # @param length track length
375
+ # @param start_time track start time, as UTC unix time
376
+ # @param album album name
377
+ # @param mbid MusicBrainz ID
378
+ #
379
+ def append(artist, title, length, start_time, album="", mbid="")
380
+ @mutex.synchronize do
381
+ track = PlayedTrack.new(artist, title, length, start_time, album, mbid)
382
+ File.open(@filename, "a") {|f| f.puts(track.serialize) } if @filename
383
+ @queue.push(track)
384
+ @condvar.signal
385
+ end
386
+ self
387
+ end
388
+
389
+ ##
390
+ # Get tracks from the beginning of the queue.
391
+ # An array of PlayedTrack objects is returned. The tracks are _not_
392
+ # removed from the queue. If the queue is empty, the method blocks
393
+ # until a track is placed on the queue.
394
+ #
395
+ # @param max_tracks maximum number of tracks to return
396
+ #
397
+ def peek(max_tracks=1)
398
+ @mutex.synchronize do
399
+ @condvar.wait(@mutex) if @queue.empty?
400
+ @queue[0, max_tracks]
401
+ end
402
+ end
403
+
404
+ ##
405
+ # Delete tracks from the beginning of the queue.
406
+ # If more tracks are requested than are in the queue, the queue is
407
+ # cleared.
408
+ #
409
+ # @param num_tracks number of tracks to delete
410
+ #
411
+ def delete(num_tracks)
412
+ if not @queue.empty?
413
+ @mutex.synchronize do
414
+ @queue = @queue[num_tracks..@queue.length-1]
415
+ if @filename
416
+ file = File.open(@filename, "w")
417
+ @queue.each {|track| file.puts(track.serialize) }
418
+ file.close
419
+ end
420
+ end
421
+ end
422
+ self
423
+ end
424
+
425
+ # A track that's been played.
426
+ class PlayedTrack
427
+ ##
428
+ # constructor
429
+ #
430
+ # @param artist artist name
431
+ # @param title track name
432
+ # @param length track length
433
+ # @param start_time track start time, as UTC unix time
434
+ # @param album album name
435
+ # @param mbid MusicBrainz ID
436
+ #
437
+ def initialize(artist, title, length, start_time, album="", mbid="")
438
+ @artist = artist.to_s
439
+ @title = title.to_s
440
+ @length = length.to_i
441
+ @start_time = start_time.to_i
442
+ @album = album ? album.to_s : ""
443
+ @mbid = mbid ? mbid.to_s : ""
444
+ end
445
+ attr_reader :artist, :title, :length, :start_time, :album, :mbid
446
+
447
+ ##
448
+ # Convert this track's information into a form suitable for writing to
449
+ # a text file. Returns a string.
450
+ #
451
+ def serialize
452
+ [@artist, @title, @length.to_s, @start_time.to_s,
453
+ @album, @mbid].collect {|x| x ? CGI.escape(x) : "" }.join("\t")
454
+ end
455
+
456
+ ##
457
+ # Undo the operation performed by serialize(), returning a new
458
+ # PlayedTrack object (class method).
459
+ #
460
+ # @param str serialized PlayedTrack to deserialize (string)
461
+ #
462
+ def self.deserialize(str)
463
+ PlayedTrack.new(*str.split("\t", 6).collect {|x|
464
+ x ? CGI.unescape(x) : "" })
465
+ end
466
+
467
+ def ==(other)
468
+ other.class == PlayedTrack and @artist == other.artist and
469
+ @title == other.title and @length == other.length and
470
+ @start_time == other.start_time and @album == other.album and
471
+ @mbid == other.mbid
472
+ end
473
+ end # class PlayedTrack
474
+ end # class SubmissionQueue
475
+ end # class Audioscrobbler