audioscrobbler 0.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/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