audioscrobbler 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/COPYING +340 -0
- data/INSTALL +232 -0
- data/MANIFEST +9 -0
- data/README +6 -0
- data/audioscrobbler.gemspec +16 -0
- data/lib/audioscrobbler.rb +475 -0
- data/setup.rb +1551 -0
- data/test/test_audioscrobbler.rb +266 -0
- data/test/test_queue.rb +86 -0
- metadata +51 -0
data/MANIFEST
ADDED
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
|