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/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
|