File Coverage

lib/Net/LastFMAPI.pm
Criterion Covered Total %
statement 102 197 51.7
branch 25 78 32.0
condition 15 43 34.8
subroutine 21 31 67.7
pod 0 12 0.0
total 163 361 45.1


line stmt bran cond sub pod time code
1             package Net::LastFMAPI;
2 1     1   26850 use strict;
  1         3  
  1         30  
3 1     1   5 use warnings;
  1         2  
  1         25  
4 1     1   10 use v5.10;
  1         7  
  1         34  
5 1     1   10753 use LWP::UserAgent;
  1         147367  
  1         41  
6 1     1   92 use Digest::MD5 'md5_hex';
  1         3  
  1         93  
7 1     1   1294 use JSON::XS;
  1         27200  
  1         2380  
8 1     1   2179 use YAML::Syck;
  1         4319  
  1         944  
9 1     1   1337 use File::Slurp;
  1         17070  
  1         87  
10 1     1   12 use File::Path 'make_path';
  1         2  
  1         64  
11 1     1   16269 use File::HomeDir 'my_home';
  1         8399  
  1         88  
12 1     1   11 use URI;
  1         2  
  1         30  
13 1     1   5 use Exporter 'import';
  1         3  
  1         47  
14             our @EXPORT = ('lastfm', 'lastfm_config', 'lastfm_iter');
15 1     1   6 use Carp;
  1         2  
  1         409  
16              
17             our $VERSION = "0.63";
18             our $url = 'http://ws.audioscrobbler.com/2.0/';
19             our $api_key = 'dfab9b1c7357c55028c84b9a8fb68880';
20             our $secret = 'd004c86dcfa8ef4c3977b04f558535f2';
21             our $session_key; # see load_save_sessionkey()
22             our $ua = new LWP::UserAgent(agent => "Net::LastFMAPI/$VERSION");
23             our $username; # not important
24             our $xml = 0;
25             our $cache = 0;
26             our $cache_dir = my_home()."/.net-lastfmapi-cache/";
27             our $sk_savefile = my_home()."/.net-lastfmapi-sessionkey";
28              
29             sub load_save_sessionkey { # see get_session_key()
30 0     0 0 0 my $key = shift;
31 0 0       0 if ($key) {
32 0         0 write_file($sk_savefile, $key);
33             }
34             else {
35 0         0 $key = eval{ read_file($sk_savefile) };
  0         0  
36             }
37 0         0 $session_key = $key;
38             }
39              
40             sub lastfm_config {
41 2     2 0 519 my %configs = @_;
42 2         5 for my $k (qw{api_key secret session_key ua xml cache cache_dir sk_savefile}) {
43 16         30 my $v = delete $configs{$k};
44 16 100       34 if (defined $v) {
45 1     1   16 no strict 'refs';
  1         2  
  1         3916  
46 1         2 ${$k} = $v;
  1         4  
47             }
48             }
49 2 100       201 croak "invalid config items: ".join(", ", keys %configs) if keys %configs;
50             }
51              
52             sub dumpfile {
53 0     0 0 0 my $file = shift;
54 0         0 my $json = encode_json(shift);
55 0         0 write_file($file, $json);
56             }
57             sub loadfile {
58 0     0 0 0 my $file = shift;
59 0         0 my $json = read_file($file);
60 0         0 decode_json($json);
61             }
62             #{{{
63             our $methods = {
64             'album.addtags' => {auth => 1, post => 1, signed => 1, id => 302},
65             'album.getbuylinks' => {id => 429},
66             'album.getinfo' => {id => 290},
67             'album.getshouts' => {page => 1, id => 450},
68             'album.gettags' => {auth => 1, signed => 1, id => 317},
69             'album.gettoptags' => {id => 438},
70             'album.removetag' => {auth => 1, post => 1, signed => 1, id => 314},
71             'album.search' => {page => 1, id => 357},
72             'album.share' => {auth => 1, post => 1, signed => 1, id => 436},
73             'artist.addtags' => {auth => 1, post => 1, signed => 1, id => 303},
74             'artist.getcorrection' => {id => 446},
75             'artist.getevents' => {page => 1, id => 117},
76             'artist.getimages' => {page => 1, id => 407},
77             'artist.getinfo' => {id => 267},
78             'artist.getpastevents' => {page => 1, id => 428},
79             'artist.getpodcast' => {id => 118},
80             'artist.getshouts' => {page => 1, id => 397},
81             'artist.getsimilar' => {id => 119},
82             'artist.gettags' => {auth => 1, signed => 1, id => 318},
83             'artist.gettopalbums' => {page => 1, id => 287},
84             'artist.gettopfans' => {id => 310},
85             'artist.gettoptags' => {id => 288},
86             'artist.gettoptracks' => {page => 1, id => 277},
87             'artist.removetag' => {auth => 1, post => 1, signed => 1, id => 315},
88             'artist.search' => {page => 1, id => 272},
89             'artist.share' => {auth => 1, post => 1, signed => 1, id => 306},
90             'artist.shout' => {auth => 1, post => 1, signed => 1, id => 408},
91             'auth.getmobilesession' => {signed => 1, id => 266},
92             'auth.getsession' => {signed => 1, id => 125},
93             'auth.gettoken' => {signed => 1, id => 265},
94             'chart.gethypedartists' => {page => 1, id => 493},
95             'chart.gethypedtracks' => {page => 1, id => 494},
96             'chart.getlovedtracks' => {page => 1, id => 495},
97             'chart.gettopartists' => {page => 1, id => 496},
98             'chart.gettoptags' => {page => 1, id => 497},
99             'chart.gettoptracks' => {page => 1, id => 498},
100             'event.attend' => {auth => 1, post => 1, signed => 1, id => 307},
101             'event.getattendees' => {page => 1, id => 391},
102             'event.getinfo' => {id => 292},
103             'event.getshouts' => {page => 1, id => 399},
104             'event.share' => {auth => 1, post => 1, signed => 1, id => 350},
105             'event.shout' => {auth => 1, post => 1, signed => 1, id => 409},
106             'geo.getevents' => {page => 1, id => 270},
107             'geo.getmetroartistchart' => {id => 421},
108             'geo.getmetrohypeartistchart' => {id => 420},
109             'geo.getmetrohypetrackchart' => {id => 422},
110             'geo.getmetrotrackchart' => {id => 423},
111             'geo.getmetrouniqueartistchart' => {id => 424},
112             'geo.getmetrouniquetrackchart' => {id => 425},
113             'geo.getmetroweeklychartlist' => {id => 426},
114             'geo.getmetros' => {id => 435},
115             'geo.gettopartists' => {page => 1, id => 297},
116             'geo.gettoptracks' => {page => 1, id => 298},
117             'group.gethype' => {id => 259},
118             'group.getmembers' => {page => 1, id => 379},
119             'group.getweeklyalbumchart' => {id => 293},
120             'group.getweeklyartistchart' => {id => 294},
121             'group.getweeklychartlist' => {id => 295},
122             'group.getweeklytrackchart' => {id => 296},
123             'library.addalbum' => {auth => 1, post => 1, signed => 1, id => 370},
124             'library.addartist' => {auth => 1, post => 1, signed => 1, id => 371},
125             'library.addtrack' => {auth => 1, post => 1, signed => 1, id => 372},
126             'library.getalbums' => {page => 1, id => 321},
127             'library.getartists' => {page => 1, id => 322},
128             'library.gettracks' => {page => 1, id => 323},
129             'library.removealbum' => {auth => 1, post => 1, signed => 1, id => 523},
130             'library.removeartist' => {auth => 1, post => 1, signed => 1, id => 524},
131             'library.removescrobble' => {auth => 1, post => 1, signed => 1, id => 525},
132             'library.removetrack' => {auth => 1, post => 1, signed => 1, id => 526},
133             'playlist.addtrack' => {auth => 1, post => 1, signed => 1, id => 337},
134             'playlist.create' => {auth => 1, post => 1, signed => 1, id => 365},
135             'radio.getplaylist' => {auth => 1, signed => 1, id => 256},
136             'radio.search' => {id => 418},
137             'radio.tune' => {auth => 1, post => 1, signed => 1, id => 160},
138             'tag.getinfo' => {id => 452},
139             'tag.getsimilar' => {id => 311},
140             'tag.gettopalbums' => {page => 1, id => 283},
141             'tag.gettopartists' => {page => 1, id => 284},
142             'tag.gettoptags' => {id => 276},
143             'tag.gettoptracks' => {page => 1, id => 285},
144             'tag.getweeklyartistchart' => {id => 358},
145             'tag.getweeklychartlist' => {id => 359},
146             'tag.search' => {page => 1, id => 273},
147             'tasteometer.compare' => {id => 258},
148             'tasteometer.comparegroup' => {id => 500},
149             'track.addtags' => {auth => 1, post => 1, signed => 1, id => 304},
150             'track.ban' => {auth => 1, post => 1, signed => 1, id => 261},
151             'track.getbuylinks' => {id => 431},
152             'track.getcorrection' => {id => 447},
153             'track.getfingerprintmetadata' => {id => 441},
154             'track.getinfo' => {id => 356},
155             'track.getshouts' => {page => 1, id => 453},
156             'track.getsimilar' => {id => 319},
157             'track.gettags' => {auth => 1, signed => 1, id => 320},
158             'track.gettopfans' => {id => 312},
159             'track.gettoptags' => {id => 289},
160             'track.love' => {auth => 1, post => 1, signed => 1, id => 260},
161             'track.removetag' => {auth => 1, post => 1, signed => 1, id => 316},
162             'track.scrobble' => {auth => 1, post => 1, signed => 1, id => 443},
163             'track.search' => {page => 1, id => 286},
164             'track.share' => {auth => 1, post => 1, signed => 1, id => 305},
165             'track.unban' => {auth => 1, post => 1, signed => 1, id => 449},
166             'track.unlove' => {auth => 1, post => 1, signed => 1, id => 440},
167             'track.updatenowplaying' => {auth => 1, post => 1, signed => 1, id => 454},
168             'user.getartisttracks' => {page => 1, id => 432},
169             'user.getbannedtracks' => {page => 1, id => 448},
170             'user.getevents' => {page => 1, id => 291},
171             'user.getfriends' => {page => 1, id => 263},
172             'user.getinfo' => {auth => 1, id => 344},
173             'user.getlovedtracks' => {page => 1, id => 329},
174             'user.getneighbours' => {id => 264},
175             'user.getnewreleases' => {id => 444},
176             'user.getpastevents' => {page => 1, id => 343},
177             'user.getpersonaltags' => {page => 1, id => 455},
178             'user.getplaylists' => {id => 313},
179             'user.getrecentstations' => {auth => 1, signed => 1, page => 1, id => 414},
180             'user.getrecenttracks' => {page => 1, id => 278},
181             'user.getrecommendedartists' => {auth => 1, signed => 1, page => 1, id => 388},
182             'user.getrecommendedevents' => {auth => 1, signed => 1, page => 1, id => 375},
183             'user.getshouts' => {page => 1, id => 401},
184             'user.gettopalbums' => {page => 1, id => 299},
185             'user.gettopartists' => {page => 1, id => 300},
186             'user.gettoptags' => {id => 123},
187             'user.gettoptracks' => {page => 1, id => 301},
188             'user.getweeklyalbumchart' => {id => 279},
189             'user.getweeklyartistchart' => {id => 281},
190             'user.getweeklychartlist' => {id => 280},
191             'user.getweeklytrackchart' => {id => 282},
192             'user.shout' => {auth => 1, post => 1, signed => 1, id => 411},
193             'venue.getevents' => {id => 394},
194             'venue.getpastevents' => {page => 1, id => 395},
195             'venue.search' => {page => 1, id => 396},
196             };
197             #}}}
198             our %last_params;
199             our $last_response;
200             our %last_response_meta;
201             sub lastfm {
202 4     4 0 7159 my ($method, @params) = @_;
203 4         10 $method = lc($method);
204              
205 4         8 my %params;
206 4         23 my $i = 0;
207 4         16 while (my $p = shift @params) {
208 57 100       91 if (ref $p eq "HASH") {
209 55         118 while (my ($k,$v) = each %$p) {
210 165         718 $params{$k."[".$i."]"} = $v;
211             }
212 55 100       229 croak "too multitudinous (limit 50)" if $i > 49;
213 54         117 $i++
214             }
215             else {
216 2         9 $params{$p} = shift @params;
217             }
218             }
219 3         8 $params{method} = $method;
220 3         8 $params{api_key} = $api_key;
221 3 50 66     19 $params{format} = "json" unless $params{format} || $xml;
222 3 100 66     25 delete $params{format} if $params{format} && $params{format} eq "xml";
223              
224 3 50 33     26 unless (exists $methods->{$method}) {
    50          
225 0         0 carp "method $method is not known to Net::LastFMAPI"
226             }
227             elsif (defined $params{page} && !$methods->{$method}->{page}) {
228 0         0 carp "method $method is not known to be paginated, but hey"
229             }
230              
231 3         9 sessionise(\%params);
232              
233 3         13 sign(\%params);
234              
235 3         26 %last_params = %params;
236              
237 3         7 my $cache = $cache;
238 3 50       9 if ( $cache ) {
239 0 0       0 unless ( -d $cache ) {
240 0         0 $cache = $cache_dir;
241 0         0 make_path( $cache );
242             }
243 0         0 my $cache_key_json = encode_json( [ map { $_, $params{$_} } sort keys %params ] );
  0         0  
244 0         0 my $file = "$cache/" . md5_hex( $cache_key_json );
245 0 0       0 if ( -f $file ) {
246 0         0 my $data = loadfile( $file );
247 0         0 return _rowify_content( $data->{content} );
248             }
249              
250 0         0 $cache = $file;
251             }
252              
253 3         6 my $res;
254 3 100       10 if ($methods->{$method}->{post}) {
255 1         6 $res = $ua->post($url, Content => \%params);
256             }
257             else {
258 2         18 my $uri = URI->new($url);
259 2         10334 $uri->query_form(%params);
260 2         383 $res = $ua->get($uri);
261             }
262              
263 3   100     678 $params{format} ||= "xml";
264 3         11 my $content = $res->decoded_content;
265 3 50       23 croak "Last.fm contains faulty data for a piece of data you requested and "
266             . "is unable to return a useful reply. Will be treated as an empty reply."
267             if $content eq qq|""\n|;
268              
269 3     2   15 my $decoded_json = sub { $content = decode_json($content); };
  2         82  
270 3 50 66     12 unless ($res->is_success &&
      33        
271             ($params{format} eq "json" && !exists($decoded_json->()->{error})
272             || $params{format} eq "xml" && $content =~ //)) {
273              
274 0         0 my @clues;
275 0 0       0 if ($res->is_success) {
276 0 0       0 if ($res->decoded_content =~ /Invalid session key - Please re-authenticate/) {
    0          
277 0         0 push @clues, "Set NET_LASTFMAPI_REAUTH=1 to re-authenticate";
278             }
279             elsif ($methods->{$method}) {
280 0         0 push @clues, "Documentation for the '$method' method:\n"
281             ." http://www.last.fm/api/show/?service=$methods->{$method}->{id}"
282             }
283             }
284              
285 0 0       0 if (ref $content eq "HASH") {
286 0         0 $content = "Content translated JSON->YAML:\n".Dump($content);
287             }
288             else {
289 0         0 $content = "Content:\n$content";
290             }
291              
292 0         0 croak join("\n",
293             "Something went wrong.",
294             "HTTP Status: ".$res->status_line,
295             @clues,
296             "",
297             $content,
298             ""
299             );
300             }
301              
302 3 50       24 if ($cache) {
303 0         0 dumpfile($cache, {content => $content});
304             }
305 3         4 $last_response = $content;
306 3         11 return _rowify_content( $content );
307             }
308              
309             sub _rowify_content {
310 3     3   5 my ( $content ) = @_;
311 3 50       8 return extract_rows( $content ) if wantarray;
312 3         28 return $content;
313             }
314              
315             sub extract_rows {
316 0     0 0 0 my ( $content ) = @_;
317 0 0       0 if (!$last_params{format}) {
318 0         0 croak "returning rows from xml is not supported";
319             }
320 0         0 my @main_keys = keys %{$content};
  0         0  
321 0         0 my $main_data = $content->{$main_keys[0]};
322 0         0 my @data_keys = sort keys %{$main_data};
  0         0  
323 0 0 0     0 unless (@main_keys == 1 && @data_keys == 2 && $data_keys[0] eq '@attr') {
      0        
324 0         0 my ( $text, $total ) = ( $main_data->{'#text'}, $main_data->{total} );
325 0 0 0     0 return if defined $text && $text =~ /^\s+$/ && defined $total && $total == 0; # no rows
      0        
      0        
326 0         0 carp "extracting rows may be broken";
327             }
328 0         0 %last_response_meta = %{ $main_data->{$data_keys[0]} };
  0         0  
329 0         0 my $rows = $main_data->{$data_keys[1]};
330 0 0       0 if (ref $rows ne "ARRAY") {
331             # schemaless translation of xml to data creates these cases
332 0 0       0 if (ref $rows eq "HASH") { # 1 row
    0          
333 0         0 $rows = [ $rows ];
334             }
335             elsif ($rows =~ /^\s+$/) { # no rows
336 0         0 $rows = [];
337 0         0 carp "got whitespacey string instead of empty row array, this happens"
338             }
339             else {
340 0         0 carp "not an array of rows... '$rows' returning ()";
341             }
342             }
343 0         0 return @$rows;
344             }
345              
346             sub lastfm_iter {
347 0     0 0 0 my @rows = lastfm(@_, page => 1);
348 0         0 my $params = { %last_params };
349 0 0       0 if (!$params->{format}) {
350 0         0 croak "paginating xml is not supported";
351             }
352 0 0       0 if (@rows == 0) {
353 0     0   0 return sub { };
  0         0  
354             }
355 0         0 my $page = $last_response_meta{page};
356 0         0 my $totalpages = $last_response_meta{totalPages};
357             my $next_page = sub {
358 0 0   0   0 return () if $page++ >= $totalpages;
359 0         0 my %params = %$params;
360 0         0 $params{page} = $page;
361 0         0 my $method = delete $params{method};
362 0         0 my @rows = lastfm($method, %params);
363 0         0 return @rows;
364 0         0 };
365             return sub {
366 0 0   0   0 unless (@rows) {
367 0         0 push @rows, $next_page->();
368             }
369 0         0 return shift @rows;
370             }
371 0         0 }
372              
373             sub sessionise {
374 3     3 0 5 my $params = shift;
375 3         10 my $m = $methods->{$params->{method}};
376 3 50 33     29 unless (delete $params->{auth} || $m && $m->{auth}) {
      33        
377             return
378 0         0 }
379 3         7 $params->{sk} = get_session_key();
380             }
381              
382             sub get_session_key {
383 3 50   3 0 11 unless (defined $session_key) {
384 0         0 load_save_sessionkey()
385             }
386 3 50       8 unless (defined $session_key) {
387 0         0 my $key;
388 0         0 eval { $key = request_session(); };
  0         0  
389 0 0       0 if ($@) {
390 0         0 die "--- Died while making requests to get a session:\n$@";
391             }
392 0         0 load_save_sessionkey($key);
393             }
394 3   50     12 return $session_key || die "unable to acquire session key...";
395             }
396              
397             sub request_session {
398 0     0 0 0 my $res = lastfm("auth.gettoken", format => "xml");
399              
400 0 0       0 my ($token) = $res =~ m{(.+)}
401             or die "no foundo token: $res";
402              
403 0         0 talk_authorisation($token);
404              
405 0         0 my $sess = lastfm("auth.getSession", token => $token, format => "xml");
406              
407 0 0       0 ($username) = $sess =~ m{(.+)}
408             or die "no name!? $sess";
409 0 0       0 my ($key) = $sess =~ m{(.+)}
410             or die "no key!? $sess";
411 0         0 return $key;
412             }
413              
414              
415             sub talk_authorisation {
416 0     0 0 0 my $token = shift;
417 0         0 say "Sorry about this but could you go over here: "
418             ."http://www.last.fm/api/auth/?api_key="
419             .$api_key."&token=".$token;
420 0         0 say "Hit enter to continue...";
421 0         0 ;
422             }
423              
424             sub sign {
425 3     3 0 6 my $params = shift;
426 3 100       12 return unless $methods->{$params->{method}}->{signed};
427 15   66     32 my $jumble = join "", map { $_ => $params->{$_} }
  16         57  
428 1         17 grep { !($_ eq "format" || $_ eq "callback") } sort keys %$params;
429 1         15 my $hash = md5_hex($jumble.$secret);
430 1         4 $params->{api_sig} = $hash;
431             }
432              
433             if ($ENV{NET_LASTFMAPI_REAUTH}) {
434             say "Re-authenticatinging...";
435             if (-e $sk_savefile) {
436             unlink($sk_savefile);
437             }
438             undef $session_key;
439             get_session_key();
440             say "Got session key: $session_key";
441             say "Unsetting NET_LASTFMAPI_REAUTH...";
442             delete $ENV{NET_LASTFMAPI_REAUTH};
443             say "Done";
444             exit;
445             }
446              
447             1;
448              
449             __END__