lastfm: mention "events at <venue>" in the help
[rbot] / data / rbot / plugins / lastfm.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: lastfm plugin for rbot
5 #
6 # Author:: Jeremy Voorhis
7 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
8 # Author:: Casey Link <unnamedrambler@gmail.com>
9 # Author:: Raine Virta <rane@kapsi.fi>
10 #
11 # Copyright:: (C) 2005 Jeremy Voorhis
12 # Copyright:: (C) 2007 Giuseppe Bilotta
13 # Copyright:: (C) 2008 Casey Link
14 # Copyright:: (C) 2009 Raine Virta
15 #
16 # License:: GPL v2
17
18 require 'rexml/document'
19 require 'cgi'
20
21 class ::LastFmEvent
22   attr_reader :attendance, :date
23
24   def initialize(hash)
25     @url = hash[:url]
26     @date = hash[:date]
27     @location = hash[:location]
28     @description = hash[:description]
29     @attendance = hash[:attendance]
30
31     @artists = hash[:artists]
32
33     if @artists.length > 10 #more than 10 artists and it floods
34       diff = @artists.length - 10
35       @artist_string = Bold + @artists[0..10].join(', ') + Bold
36       @artist_string << _(" and %{n} more...") % {:n => diff}
37     else
38       @artist_string = Bold + @artists.join(', ') + Bold
39     end
40   end
41
42   def compact_display
43    unless @attendance.zero?
44      return "%s %s @ %s (%s attending) %s" % [@date.strftime("%a, %b %d"), @artist_string, @location, @attendance, @url]
45    end
46    return "%s %s @ %s %s" % [@date.strftime("%a, %b %d"), @artist_string, @location, @url]
47   end
48   alias :to_s :compact_display
49
50 end
51
52 define_structure :LastFmVenue, :id, :city, :street, :postal, :country, :name, :url, :lat, :long
53
54 class LastFmPlugin < Plugin
55   include REXML
56   Config.register Config::IntegerValue.new('lastfm.max_events',
57     :default => 25, :validate => Proc.new{|v| v > 1},
58     :desc => "Maximum number of events to display.")
59   Config.register Config::IntegerValue.new('lastfm.default_events',
60     :default => 3, :validate => Proc.new{|v| v > 1},
61     :desc => "Default number of events to display.")
62   Config.register Config::IntegerValue.new('lastfm.max_shouts',
63     :default => 5, :validate => Proc.new{|v| v > 1},
64     :desc => "Maximum number of user shouts to display.")
65   Config.register Config::IntegerValue.new('lastfm.default_shouts',
66     :default => 3, :validate => Proc.new{|v| v > 1},
67     :desc => "Default number of user shouts to display.")
68   Config.register Config::IntegerValue.new('lastfm.max_user_data',
69     :default => 25, :validate => Proc.new{|v| v > 1},
70     :desc => "Maximum number of user data entries (except events and shouts) to display.")
71   Config.register Config::IntegerValue.new('lastfm.default_user_data',
72     :default => 10, :validate => Proc.new{|v| v > 1},
73     :desc => "Default number of user data entries (except events and shouts) to display.")
74
75   APIKEY = "b25b959554ed76058ac220b7b2e0a026"
76   APIURL = "http://ws.audioscrobbler.com/2.0/?api_key=#{APIKEY}&"
77
78   def initialize
79     super
80     class << @registry
81       def store(val)
82         val
83       end
84       def restore(val)
85         val
86       end
87     end
88   end
89
90   def help(plugin, topic="")
91     period = _(", where <period> can be one of: 3|6|12 months, a year")
92     case (topic.intern rescue nil)
93     when :event, :events
94       _("lastfm [<num>] events in <location> => show information on events in or near <location>. lastfm [<num>] events by <artist/group> => show information on events by <artist/group>. lastfm [<num>] events at <venue> => show information on events at specific <venue>. The number of events <num> that can be displayed is optional, defaults to %{d} and cannot be higher than %{m}. Append 'sort by <what> [in <order> order]' to sort events. Events can be sorted by attendance or date (default) in ascending or descending order.") % {:d => @bot.config['lastfm.default_events'], :m => @bot.config['lastfm.max_events']}
95     when :artist
96       _("lastfm artist <name> => show information on artist <name> from last.fm")
97     when :album
98       _("lastfm album <name> => show information on album <name> from last.fm [not implemented yet]")
99     when :track
100       _("lastfm track <name> => search tracks matching <name> on last.fm")
101     when :now, :np
102       _("lastfm now [<nick>] => show the now playing track from last.fm. np [<nick>] does the same.")
103     when :set
104       _("lastfm set user <user> => associate your current irc nick with a last.fm user. lastfm set verb <present>, <past> => set your preferred now playing/just played verbs. default \"is listening to\" and \"listened to\".")
105     when :who
106       _("lastfm who [<nick>] => show who <nick> is on last.fm. if <nick> is empty, show who you are on lastfm.")
107     when :compare
108       _("lastfm compare [<nick1>] <nick2> => show musical taste compatibility between nick1 (or user if omitted) and nick2")
109     when :shouts
110       _("lastfm shouts [<nick>] => show shouts to <nick>")
111     when :friends
112       _("lastfm friends [<nick>] => show <nick>'s friends")
113     when :neighbors, :neighbours
114       _("lastfm neighbors [<nick>] => show people who share similar musical taste as <nick>")
115     when :lovedtracks
116       _("lastfm loved[tracks] [<nick>] => show tracks that <nick> has loved")
117     when :recenttracks, :recentracks
118       _("lastfm recent[tracks] [<nick>] => show tracks that <nick> has recently played")
119     when :topalbums
120       _("lastfm topalbums [<nick>] [over <period>] => show <nick>'s top albums%{p}") % { :p => period }
121     when :topartists
122       _("lastfm topartists [<nick>] [over <period>] => show <nick>'s top artists%{p}") % { :p => period }
123     when :toptracks
124       _("lastfm toptracks [<nick>] [over <period>] => show <nick>'s top tracks%{p}") % { :p => period }
125     when :weeklyalbumchart
126       _("lastfm weeklyalbumchart [<nick>] => show <nick>'s weekly album chart")
127     when :weeklyartistchart
128       _("lastfm weeklyartistchart [<nick>] => show <nick>'s weekly artist chart")
129     when :weeklytrackchart
130       _("lastfm weeklyartistchart [<nick>] => show <nick>'s weekly track chart")
131     else
132       _("last.fm plugin - topics: events, artist, album, track, now, set, who, compare, shouts, friends, neighbors, (loved|recent)tracks, top(albums|tracks|artists), weekly(album|artist|track)chart")
133     end
134   end
135
136   # TODO allow searching by country etc.
137   #
138   # Options: name, limit
139   def search_venue_by(options)
140     params = {}
141     params[:venue] = CGI.escape(options[:name])
142     options.delete(:name)
143     params.merge!(options)
144
145     uri = "#{APIURL}method=venue.search&"
146     uri << params.to_a.map {|e| e.join("=")}.join("&")
147
148     xml = @bot.httputil.get_response(uri)
149     doc = Document.new xml.body
150     results = []
151
152     doc.root.elements.each("results/venuematches/venue") do |v|
153       venue = LastFmVenue.new
154       venue.id      = v.elements["id"].text.to_i
155       venue.url     = v.elements["url"].text
156       venue.lat     = v.elements["location/geo:point/geo:lat"].text.to_f
157       venue.long    = v.elements["location/geo:point/geo:long"].text.to_f
158       venue.name    = v.elements["name"].text
159       venue.city    = v.elements["location/city"].text
160       venue.street  = v.elements["location/street"].text
161       venue.postal  = v.elements["location/postalcode"].text
162       venue.country = v.elements["location/country"].text
163
164       results << venue
165     end
166     results
167   end
168
169   def find_events(m, params)
170     num = params[:num] || @bot.config['lastfm.default_events']
171     num = num.to_i.clip(1, @bot.config['lastfm.max_events'])
172
173     sort_by    = params[:sort_by] || :date
174     sort_order = params[:sort_order]
175     sort_order = sort_order.to_sym unless sort_order.nil?
176
177     location = params[:location]
178     artist = params[:who]
179     venue = params[:venue]
180     user = resolve_username(m, params[:user])
181
182     if location
183       uri = "#{APIURL}method=geo.getevents&location=#{CGI.escape location.to_s}"
184       emptymsg = _("no events found in %{location}") % {:location => location.to_s}
185     elsif venue
186       venues = search_venue_by(:name => venue.to_s, :limit => 1)
187       venue  = venues.first
188       uri = "#{APIURL}method=venue.getevents&venue=#{venue.id}"
189     elsif artist
190       uri = "#{APIURL}method=artist.getevents&artist=#{CGI.escape artist.to_s}"
191       emptymsg = _("no events found by %{artist}") % {:artist => artist.to_s}
192     elsif user
193       uri = "#{APIURL}method=user.getevents&user=#{CGI.escape user}"
194       emptymsg = _("%{user} is not attending any events") % {:user => user}
195     end
196     xml = @bot.httputil.get_response(uri)
197
198     doc = Document.new xml.body
199     if xml.class == Net::HTTPInternalServerError
200       if doc.root and doc.root.attributes["status"] == "failed"
201         m.reply doc.root.elements["error"].text
202       else
203         m.reply _("could not retrieve events")
204       end
205     end
206     disp_events = Array.new
207     events = Array.new
208     doc.root.elements.each("events/event"){ |e|
209       h = {}
210       h[:title] = e.elements["title"].text
211       venue = e.elements["venue"].elements["name"].text
212       city = e.elements["venue"].elements["location"].elements["city"].text
213       country =  e.elements["venue"].elements["location"].elements["country"].text
214       h[:location] = Underline + venue + Underline + " #{Bold + city + Bold}, #{country}"
215       date = e.elements["startDate"].text.split
216       h[:date] = Time.utc(date[3].to_i, date[2], date[1].to_i)
217       h[:desc] = e.elements["description"].text
218       h[:url] = e.elements["url"].text
219       h[:attendance] = e.elements["attendance"].text.to_i
220       artists = Array.new
221       e.elements.each("artists/artist"){ |a|
222         artists << a.text
223       }
224       h[:artists] = artists
225       events << LastFmEvent.new(h)
226     }
227     if events.empty?
228       m.reply emptymsg
229       return
230     end
231
232     # sort order when sorted by date is ascending by default
233     # and descending when sorted by attendance
234     case sort_by.to_sym
235     when :attendance
236       events = events.sort_by { |e| e.attendance }.reverse
237       events.reverse! if [:ascending, :asc].include? sort_order
238     when :date
239       events = events.sort_by { |e| e.date }
240       events.reverse! if [:descending, :desc].include? sort_order
241     end
242
243     events[0...num].each { |event|
244       disp_events << event.to_s
245     }
246     m.reply disp_events.join(' | '), :split_at => /\s+\|\s+/
247
248   end
249
250   def tasteometer(m, params)
251     opts = { :cache => false }
252     user1 = resolve_username(m, params[:user1])
253     user2 = resolve_username(m, params[:user2])
254     xml = @bot.httputil.get_response("#{APIURL}method=tasteometer.compare&type1=user&type2=user&value1=#{CGI.escape user1}&value2=#{CGI.escape user2}", opts)
255     doc = Document.new xml.body
256     unless doc
257       m.reply _("last.fm parsing failed")
258       return
259     end
260     if xml.class == Net::HTTPBadRequest
261       if doc.root.elements["error"].attributes["code"] == "7" then
262         error = doc.root.elements["error"].text
263         error.match(/Invalid username: \[(.*)\]/);
264         baduser = $1
265
266         m.reply _("%{u} doesn't exist on last.fm") % {:u => baduser}
267         return
268       else
269         m.reply _("error: %{e}") % {:e => doc.root.element["error"].text}
270         return
271       end
272     end
273     score = doc.root.elements["comparison/result/score"].text.to_f
274     artists = doc.root.get_elements("comparison/result/artists/artist").map { |e| e.elements["name"].text}
275     case
276       when score >= 0.9
277         rating = _("Super")
278       when score >= 0.7
279         rating = _("Very High")
280       when score >= 0.5
281         rating = _("High")
282       when score >= 0.3
283         rating = _("Medium")
284       when score >= 0.1
285         rating = _("Low")
286       else
287         rating = _("Very Low")
288     end
289
290     common_artists = unless artists.empty?
291       _(" and music they have in common includes: %{artists}") % {
292         :artists => Utils.comma_list(artists) }
293     else
294       nil
295     end
296
297     m.reply _("%{a}'s and %{b}'s musical compatibility rating is %{bold}%{r}%{bold}%{common}") % {
298       :a => user1,
299       :b => user2,
300       :r => rating.downcase,
301       :bold => Bold,
302       :common => common_artists
303     }
304   end
305
306   def now_playing(m, params)
307     opts = { :cache => false }
308     user = resolve_username(m, params[:who])
309     xml = @bot.httputil.get_response("#{APIURL}method=user.getrecenttracks&user=#{CGI.escape user}", opts)
310     doc = Document.new xml.body
311     unless doc
312       m.reply _("last.fm parsing failed")
313       return
314     end
315     if xml.class == Net::HTTPBadRequest
316       if doc.root.elements["error"].text == "Invalid user name supplied" then
317         m.reply _("%{user} doesn't exist on last.fm, perhaps they need to: lastfm user <username>") % {
318           :user => user
319         }
320         return
321       else
322         m.reply _("error: %{e}") % {:e => doc.root.element["error"].text}
323         return
324       end
325     end
326     now = artist = track = albumtxt = date = nil
327     unless doc.root.elements[1].has_elements?
328      m.reply _("%{u} hasn't played anything recently") % {:u => user}
329      return
330     end
331     first = doc.root.elements[1].elements[1]
332     now = first.attributes["nowplaying"]
333     artist = first.elements["artist"].text
334     track = first.elements["name"].text
335     albumtxt = first.elements["album"].text
336     album = if albumtxt
337       year = get_album(artist, albumtxt)[2]
338       if year
339         _(" [%{albumtext}, %{year}]") % { :albumtext => albumtxt, :year => year }
340       else
341         _(" [%{albumtext}]") % { :albumtext => albumtxt }
342       end
343     else
344       nil
345     end
346     past = nil
347     date = XPath.first(first, "//date")
348     if date != nil
349       time = date.attributes["uts"]
350       past = Time.at(time.to_i)
351     end
352     if now == "true"
353        verb = _("is listening to")
354        if @registry.has_key? "#{m.sourcenick}_verb_present"
355          verb = @registry["#{m.sourcenick}_verb_present"]
356        end
357        reply = _("%{u} %{v} \"%{t}\" by %{a}%{b}") % {:u => user, :v => verb, :t => track, :a => artist, :b => album}
358     else
359       verb = _("listened to")
360        if @registry.has_key? "#{m.sourcenick}_verb_past"
361          verb = @registry["#{m.sourcenick}_verb_past"]
362        end
363       ago = Utils.timeago(past)
364       reply = _("%{u} %{v} \"%{t}\" by %{a}%{b} %{p}") % {:u => user, :v => verb, :t => track, :a => artist, :b => album, :p => ago}
365     end
366
367     reply << _("; see %{uri} for more") % { :uri => "http://www.last.fm/user/#{CGI.escape user}"}
368     m.reply reply
369   end
370
371   def find_artist(m, params)
372     info_xml = @bot.httputil.get("#{APIURL}method=artist.getinfo&artist=#{CGI.escape params[:artist].to_s}")
373     unless info_xml
374       m.reply _("I had problems getting info for %{a}") % {:a => params[:artist]}
375       return
376     end
377     info_doc = Document.new info_xml
378     unless info_doc
379       m.reply _("last.fm parsing failed")
380       return
381     end
382     tags_xml = @bot.httputil.get("#{APIURL}method=artist.gettoptags&artist=#{CGI.escape params[:artist].to_s}")
383     tags_doc = Document.new tags_xml
384
385     first = info_doc.root.elements["artist"]
386     artist = first.elements["name"].text
387     url = first.elements["url"].text
388     stats = {}
389     %w(playcount listeners).each do |e|
390       t = first.elements["stats/#{e}"].text
391       stats[e.to_sym] = t.gsub(/(\d)(?=(\d\d\d)+(?!\d))/, "\\1,")
392     end
393     summary = first.elements["bio"].elements["summary"].text
394     similar = first.get_elements("similar/artist").map { |a|
395       _("%{b}%{a}%{b}") % { :a => a.elements["name"].text, :b => Bold } }
396     tags = tags_doc.root.get_elements("toptags/tag")[0..4].map { |t|
397       _("%{u}%{t}%{u}") % { :t => t.elements["name"].text, :u => Underline } }
398     reply = _("%{b}%{a}%{b} <%{u}> has been played %{b}%{c}%{b} times and is being listened to by %{b}%{l}%{b} people") % {
399       :b => Bold, :a => artist, :u => url, :c => stats[:playcount], :l => stats[:listeners] }
400     reply << _(". Tagged as: %{t}") % {
401       :t => tags.join(", "), :b => Bold } unless tags.empty?
402     reply << _(". Similar artists: %{s}") % {
403       :s => similar.join(", "), :b => Bold } unless similar.empty?
404     m.reply reply
405     m.reply summary.ircify_html
406   end
407
408   def find_track(m, params)
409     track = params[:track].to_s
410     xml = @bot.httputil.get("#{APIURL}method=track.search&track=#{CGI.escape track}")
411     unless xml
412       m.reply _("I had problems getting info for %{a}") % {:a => track}
413       return
414     end
415     debug xml
416     doc = Document.new xml
417     unless doc
418       m.reply _("last.fm parsing failed")
419       return
420     end
421     debug doc.root
422     results = doc.root.elements["results/opensearch:totalResults"].text.to_i rescue 0
423     if results > 0
424       begin
425         hits = []
426         doc.root.each_element("results/trackmatches/track") do |track|
427           hits << _("%{bold}%{t}%{bold} by %{bold}%{a}%{bold} (%{n} listeners)") % {
428             :t => track.elements["name"].text,
429             :a => track.elements["artist"].text,
430             :n => track.elements["listeners"].text,
431             :bold => Bold
432           }
433         end
434         m.reply hits.join(' -- '), :split_at => ' -- '
435       rescue
436         error $!
437         m.reply _("last.fm parsing failed")
438       end
439     else
440       m.reply _("track %{a} not found") % {:a => track}
441     end
442   end
443
444   def find_venue(m, params)
445     venue  = params[:venue].to_s
446     venues = search_venue_by(:name => venue, :limit => 1)
447     venue  = venues.last
448
449     if venues.empty?
450       m.reply "sorry, can't find such venue"
451       return
452     end
453
454     reply = _("%{b}%{name}%{b}, %{street}, %{u}%{city}%{u}, %{country}, see %{url} for more info") % {
455       :u => Underline, :b => Bold, :name => venue.name, :city => venue.city, :street => venue.street,
456       :country => venue.country, :url => venue.url
457     }
458
459     if venue.street && venue.city
460       maps_uri = "http://maps.google.com/maps?q=#{venue.street},+#{venue.city}"
461       maps_uri << ",+#{venue.postal}" if venue.postal
462     elsif venue.lat && venue.long
463       maps_uri = "http://maps.google.com/maps?q=#{venue.lat},+#{venue.long}"
464     else
465       m.reply reply
466       return
467     end
468
469     maps_uri << "+(#{venue.name.gsub(" ", "%A0")})"
470
471     begin
472       require "shorturl"
473       maps_uri = ShortURL.shorten(CGI.escape(maps_uri))
474     rescue LoadError => e
475       error e
476     end
477
478     reply << _(" and %{maps} for maps") % { :maps => maps_uri, :b => Bold }
479     m.reply reply
480   end
481
482   def get_album(artist, album)
483     xml = @bot.httputil.get("#{APIURL}method=album.getinfo&artist=#{CGI.escape artist}&album=#{CGI.escape album}")
484     unless xml
485       return [_("I had problems getting album info")]
486     end
487     doc = Document.new xml
488     unless doc
489       return [_("last.fm parsing failed")]
490     end
491     album = date = playcount = artist = date = year = nil
492     first = doc.root.elements["album"]
493     artist = first.elements["artist"].text
494     playcount = first.elements["playcount"].text
495     album = first.elements["name"].text
496     date = first.elements["releasedate"].text
497     unless date.strip.length < 2
498       year = date.strip.split[2].chop
499     end
500     result = [artist, album, year, playcount]
501     return result
502   end
503
504   def find_album(m, params)
505     album = get_album(params[:artist].to_s, params[:album].to_s)
506     if album.length == 1
507       m.reply _("I couldn't locate: \"%{a}\" by %{r}") % {:a => params[:album], :r => params[:artist]}
508       return
509     end
510     year = "(#{album[2]}) " unless album[2] == nil
511     m.reply _("the album \"%{a}\" by %{r} %{y}has been played %{c} times") % {:a => album[1], :r => album[0], :y => year, :c => album[3]}
512   end
513
514   def set_user(m, params)
515     user = params[:who].to_s
516     nick = m.sourcenick
517     @registry[ nick ] = user
518     m.reply _("okay, I'll remember that %{n} is %{u} on last.fm") % {:n => nick, :u => user}
519   end
520
521   def set_verb(m, params)
522     past = params[:past].to_s
523     present = params[:present].to_s
524     key = "#{m.sourcenick}_verb_"
525     @registry[ "#{key}past" ] = past
526     @registry[ "#{key}present" ] = present
527     m.reply _("okay, I'll remember that %{n} prefers \"%{r}\" and \"%{p}\"") % {:n => m.sourcenick, :p => past, :r => present}
528   end
529
530   def get_user(m, params)
531     nick = ""
532     if params[:who]
533       nick = params[:who].to_s
534     else
535       nick = m.sourcenick
536     end
537     if @registry.has_key? nick
538       user = @registry[ nick ]
539       m.reply _("%{nick} is %{user} on last.fm") % {
540         :nick => nick,
541         :user => user
542       }
543     else
544       m.reply _("sorry, I don't know who %{n} is on last.fm, perhaps they need to: lastfm set user <username>") % {:n => nick}
545     end
546   end
547
548   def lastfm(m, params)
549     action = case params[:action]
550     when "neighbors" then "neighbours"
551     when "recentracks", "recent" then "recenttracks"
552     when "loved" then "lovedtracks"
553     when /^weekly(track|album|artist)s$/
554       "weekly#{$1}chart"
555     when "events"
556       find_events(m, params)
557       return
558     else
559       params[:action]
560     end.to_sym
561
562     if action == :shouts
563       num = params[:num] || @bot.config['lastfm.default_shouts']
564       num = num.to_i.clip(1, @bot.config['lastfm.max_shouts'])
565     else
566       num = params[:num] || @bot.config['lastfm.default_user_data']
567       num = num.to_i.clip(1, @bot.config['lastfm.max_user_data'])
568     end
569
570     user = resolve_username(m, params[:user])
571     uri = "#{APIURL}method=user.get#{action}&user=#{CGI.escape user}"
572
573     if period = params[:period]
574       period_uri = (period.last == "year" ? "12month" : period.first + "month")
575       uri << "&period=#{period_uri}"
576     end
577
578     begin
579       res = @bot.httputil.get_response(uri)
580       raise _("no response body") unless res.body
581     rescue Exception => e
582         m.reply _("I had problems accessing last.fm: %{e}") % {:e => e.message}
583         return
584     end
585     doc = Document.new(res.body)
586     unless doc
587       m.reply _("last.fm parsing failed")
588       return
589     end
590
591     case res
592     when Net::HTTPBadRequest
593       if doc.root and doc.root.attributes["status"] == "failed"
594         m.reply "error: " << doc.root.elements["error"].text.downcase
595       end
596       return
597     end
598
599     seemore =  _("; see %{uri} for more")
600     case action
601     when :friends
602       friends = doc.root.get_elements("friends/user").map do |u|
603         u.elements["name"].text
604       end
605
606       if friends.empty?
607         reply = _("%{user} has no friends :(")
608       elsif friends.length <= num
609         reply = _("%{user} has %{total} friends: %{friends}")
610       else
611         reply = _("%{user} has %{total} friends, including %{friends}%{seemore}")
612       end
613       m.reply reply % {
614         :user => user,
615         :total => friends.size,
616         :friends => Utils.comma_list(friends.shuffle[0, num]),
617         :uri => "http://www.last.fm/user/#{CGI.escape user}/friends",
618         :seemore => seemore
619       }
620     when :lovedtracks
621       loved = doc.root.get_elements("lovedtracks/track").map do |track|
622         [track.elements["artist/name"].text, track.elements["name"].text].join(" - ")
623       end
624       loved_prep = loved.shuffle[0, num].to_enum(:each_with_index).collect { |e,i| (i % 2).zero? ? Underline+e+Underline : e }
625
626       if loved.empty?
627         reply = _("%{user} has not loved any tracks")
628       elsif loved.length <= num
629         reply = _("%{user} has loved %{total} tracks: %{tracks}")
630       else
631         reply = _("%{user} has loved %{total} tracks, including %{tracks}%{seemore}")
632       end
633       m.reply reply % {
634           :user => user,
635           :total => loved.size,
636           :tracks => Utils.comma_list(loved_prep),
637           :uri => "http://www.last.fm/user/#{CGI.escape user}/library/loved",
638           :seemore => seemore
639         }
640     when :neighbours
641       nbrs = doc.root.get_elements("neighbours/user").map do |u|
642         u.elements["name"].text
643       end
644
645       if nbrs.empty?
646         reply = _("no one seems to share %{user}'s musical taste")
647       elsif nbrs.length <= num
648         reply = _("%{user}'s musical neighbours are %{nbrs}")
649       else
650         reply = _("%{user}'s musical neighbours include %{nbrs}%{seemore}")
651       end
652       m.reply reply % {
653           :user    => user,
654           :nbrs    => Utils.comma_list(nbrs.shuffle[0, num]),
655           :uri     => "http://www.last.fm/user/#{CGI.escape user}/neighbours",
656           :seemore => seemore
657       }
658     when :recenttracks
659       tracks = doc.root.get_elements("recenttracks/track").map do |track|
660         [track.elements["artist"].text, track.elements["name"].text].join(" - ")
661       end
662
663       counts = []
664       tracks.each do |track|
665         if t = counts.assoc(track)
666           counts[counts.rindex(t)] = [track, t[-1] += 1]
667         else
668           counts << [track, 1]
669         end
670       end
671
672       tracks_prep = counts[0, num].to_enum(:each_with_index).map do |e,i|
673         str = (i % 2).zero? ? Underline+e[0]+Underline : e[0]
674         str << " (%{i} times%{m})" % {
675           :i => e.last,
676           :m => counts.size == 1 ? _(" or more") : nil
677         } if e.last > 1
678         str
679       end
680
681       if tracks.empty?
682         m.reply _("%{user} hasn't played anything recently") % { :user => user }
683       else
684         m.reply _("%{user} has recently played %{tracks}") %
685           { :user => user, :tracks => Utils.comma_list(tracks_prep) }
686       end
687     when :shouts
688       shouts = doc.root.get_elements("shouts/shout")
689       if shouts.empty?
690         m.reply _("there are no shouts for %{user}") % { :user => user }
691       else
692         shouts[0, num].each do |shout|
693           m.reply _("<%{author}> %{body}") % {
694             :body   => shout.elements["body"].text,
695             :author => shout.elements["author"].text,
696           }
697         end
698       end
699     when :toptracks, :topalbums, :topartists, :weeklytrackchart, :weeklyalbumchart, :weeklyartistchart
700       type  = action.to_s.scan(/track|album|artist/).to_s
701       items = doc.root.get_elements("#{action}/#{type}").map do |item|
702         case action
703         when :weeklytrackchart, :weeklyalbumchart
704           format = "%{artist} - %{title} (%{bold}%{plays}%{bold})"
705           artist = item.elements["artist"].text
706         when :weeklyartistchart, :topartists
707           format = "%{artist} (%{bold}%{plays}%{bold})"
708           artist = item.elements["name"].text
709         when :toptracks, :topalbums
710           format = "%{artist} - %{title} (%{bold}%{plays}%{bold})"
711           artist = item.elements["artist/name"].text
712         end
713
714         _(format) % {
715           :artist => artist,
716           :title  => item.elements["name"].text,
717           :plays  => item.elements["playcount"].text,
718           :bold   => Bold
719         }
720       end
721       if items.empty?
722         m.reply _("%{user} hasn't played anything in this period of time") % { :user => user }
723       else
724         m.reply items[0, num].join(", ")
725       end
726     end
727   end
728
729   def resolve_username(m, name)
730     name = m.sourcenick if name.nil?
731     @registry[name] or name
732   end
733 end
734
735 plugin = LastFmPlugin.new
736 plugin.map 'lastfm [:num] event[s] in *location [sort by :sort_by] [in] [:sort_order] [order]', :action => :find_events, :requirements => { :num => /\d+/ }, :thread => true
737 plugin.map 'lastfm [:num] event[s] by *who [sort by :sort_by] [in] [:sort_order] [order]', :action => :find_events, :requirements => { :num => /\d+/ }, :thread => true
738 plugin.map 'lastfm [:num] event[s] at *venue [sort by :sort_by] [in] [:sort_order] [order]', :action => :find_events, :requirements => { :num => /\d+/ }, :thread => true
739 plugin.map 'lastfm [:num] event[s] [for] *who [sort by :sort_by] [in] [:sort_order] [order]', :action => :find_events, :requirements => { :num => /\d+/ }, :thread => true
740 plugin.map 'lastfm artist *artist', :action => :find_artist, :thread => true
741 plugin.map 'lastfm album *album [by *artist]', :action => :find_album
742 plugin.map 'lastfm track *track', :action => :find_track, :thread => true
743 plugin.map 'lastfm venue *venue', :action => :find_venue, :thread => true
744 plugin.map 'lastfm set user[name] :who', :action => :set_user, :thread => true
745 plugin.map 'lastfm set verb *present, *past', :action => :set_verb, :thread => true
746 plugin.map 'lastfm who [:who]', :action => :get_user, :thread => true
747 plugin.map 'lastfm compare to :user2', :action => :tasteometer, :thread => true
748 plugin.map 'lastfm compare [:user1] [to] :user2', :action => :tasteometer, :thread => true
749 plugin.map "lastfm [user] [:num] :action [:user]", :thread => true,
750   :requirements => {
751     :action => /^(?:events|shouts|friends|neighbou?rs|loved(?:tracks)?|recent(?:t?racks)?|top(?:album|artist|track)s?|weekly(?:albums?|artists?|tracks?)(?:chart)?)$/,
752     :num => /^\d+$/
753 }
754 plugin.map 'lastfm [user] [:num] :action [:user] over [*period]', :thread => true,
755   :requirements => {
756     :action => /^(?:top(?:album|artist|track)s?)$/,
757     :period => /^(?:(?:3|6|12) months)|(?:a\s|1\s)?year$/,
758     :num => /^\d+$/
759 }
760 plugin.map 'lastfm [now] [:who]', :action => :now_playing, :thread => true
761 plugin.map 'np [:who]', :action => :now_playing, :thread => true