The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/perl

# last.fm-ripper - (c) Copyright 2006  Jochen Schneider <scne59@googlemail.com>
# 
# a simple last.fm to mp3-file ripper 
# 
# This program is free software; you can redistribute it and/or modify 
# it under the same terms as Perl itself.

$VERSION = 1.2;

$version = '1.1.4'; # current version string of the offical last.fm player
$platform = guess_platform();
$ws_host = 'ws.audioscrobbler.com'; # last.fm webservices hostname
$output_directory = '.'; # where to store mp3 files
$get_covers = 1 unless (system("wget --version > /dev/null")==-1); # retrieve covers if wget is available

# check if all essential modules are in place
map
{
  my $module = $_;
  unless (eval "use $module; 1")
  {
    print "Module \"$module\" is not availabe on your Perl installation\nEnter \"perl -MCPAN -e \"install $module\" to install it\n\n";
    exit(1);
  }
} ('Getopt::Long','IO::Socket','FileHandle','Digest::MD5','IO::Select');

# can we tag the mp3 files?
if(eval "use MP3::Tag; 1")
{
  $tag_mp3s = 1;
}

STDOUT->autoflush;
$SIG{INT} = sub { $bye_bye = 1 };

Getopt::Long::Configure("pass_through");
GetOptions(  'help|?' => \$help,
             'debug|d' => \$debug,
             'no_covers|n' => $no_covers,
             'artist|a=s' => \$artist,
             'username|u=s' => \$username,
             'password|p=s' => \$password,
             'output_dir|o=s' => \$output_directory,
             'aws_token|w=s' => \$aws_token);
             
# let's see if params are correct and what our mission goals are
if($help)
{
  usage();
  exit;
}
if($no_covers)
{
  undef $get_covers;
}
if($username eq '')
{
  print "missing username\n";
  exit(1);
}
if(!-d $output_directory)
{
  print "output directory does not exist.\n";
  exit(1);
}
# will we listen to a supplied lastfm-url or will we roll our own for a similar-artist scheme             
if ($ARGV[0]=~/lastfm\:\/\//)
{
  $url=$ARGV[0]
}
elsif ($artist)
{
  $artist=~s/\s/\%20/g;
  $url="lastfm://artist/".$artist."/similarartists";
}
else
{
  print "please supply an artist name or last.fm url\n";
  exit(1);
}

if($password eq '')
{
  # we have to ask for a password
  # and let Term::ReadPassword do the job if available
  if (eval ("use Term::ReadPassword; 1"))
  {
    $password = read_password('password: ');
  }
  else
  {
    print "password: ";
    $password = <STDIN>;
    chomp($password); 
  }
}

if($aws_token)
{
  # we have a amazon-webservices-token. Is Net::Amazon working?
  map
  {
    my $module = $_;
    if(!eval ("use $module; 1"))
      {
        die "could not initialize $module\n";
      }
  } ('Net::Amazon', 'Net::Amazon::Request::Artist');
}

# last.fm uses a high-security password-obfuscation ;-)
$password_md5 = md5_password($password);

debug("Now trying to play URL $url for user $username with pass $password - md5hash: $password_md5\n");

$sockets = IO::Select->new();

while (1)
{
  $buffer = '';
  if (!$handshake)
  {
    debug("trying to log in\n");
    $handshake = IO::Socket::INET->new(PeerAddr => $ws_host, PeerPort => 80, Proto => "tcp") || die "could not initialize webservice socket\n";
    $handshake_url = "/radio/handshake.php?version=$version&platform=$platform&username=$username&passwordmd5=$password_md5&debug=0";
    $request = "GET $handshake_url HTTP/1.1\r\nhost: $ws_host\r\n\r\n";
    print $handshake $request;
    $sockets->add($handshake);
  }
  elsif (!$tuned)
  {
    debug("trying to tune station\n");
    $tune = IO::Socket::INET->new(PeerAddr => $ws_host, PeerPort => 80, Proto => "tcp") || die "could not initialize webservice socket\n";
    $tune_url = "/radio/adjust.php?session=$session&url=$url&debug=0";
    $request = "GET $tune_url HTTP/1.1\r\nhost: $ws_host\r\n\r\n";
    print $tune $request;
    $sockets->add($tune);
  }
  elsif (!$streaming)
  {
    debug("requesting streaming data from $mp3_host\n");
    $mp3 = IO::Socket::INET->new(PeerAddr => $mp3_host, PeerPort => 80, Proto => "tcp") || die "could not initialize mp3 socket\n";
    $mp3_url = "/last.mp3?Session=$session";
    $request = "GET $mp3_url HTTP/1.1\r\nhost: $mp3_host\r\n\r\n";
    print $mp3 $request;
    $sockets->add($mp3);
    $new_track = 1;
    $streaming = 1;
  }
  elsif ($new_track)
  {
    # are we receiving mp3 data? - then ask for track info!
    if (length($mp3_data) >= 4096)
    {
      debug("trying to get new track data\n");
      sleep 3;
      $track_info = IO::Socket::INET->new(PeerAddr => $ws_host, PeerPort => 80, Proto => "tcp") || die "could not initialize webservice socket\n";
      $track_data_url = "/radio/np.php?session=$session";
      $request = "GET $track_data_url HTTP/1.1\r\nhost: $ws_host\r\n\r\n";
      print $track_info $request;
      $sockets->add($track_info);
      undef $new_track;
    }
  }
  elsif ($track_info_data)
  {
    debug("parsing track_info_data\n");
    map
    {
      chomp();
      debug("$_\n");
      /(.+)=(.+)/;
      $track_info{$1} = $2;
    } split /\n/,$track_info_data;
    undef $track_info_data;
    $sockets->remove(track_info);
    $track_info->shutdown(2);
    if ($get_covers)
    {
      # try to retrieve the largest available cover jpeg with wget
      map 
      {
        if ($_ ne '')
        {
          $cover = $_;
        }
      } ($track_info{'albumcover_small'},$track_info{'albumcover_medium'},$track_info{'albumcover_large'});
      if ($cover ne '')
      {
        $cover_file = "$track_info{'artist'}-$track_info{'album'}-$track_info{'track'}.jpg";
        if(system("wget -O \"$cover_file\" $cover > /dev/null 2>&1 &")>0){print "failed to retrieve cover art\n"};
        $cover = '';
        }
      }
      
      if ($aws_token)
      {
        my $aws_ua = Net::Amazon->new( token => $aws_token);
        my $aws_request = Net::Amazon::Request::Artist->new(artist  => $track_info{'artist'});
        my $aws_response = $aws_ua->request($aws_request);
        if ($aws_response->is_success())
        {
          my %albums;
          map
          {
            my $album = $_;
            if ($album->album() eq $track_info{'album'})
            {
              $track_info{'year'} = $album->year();
              $track_info{'label'} = $album->label();
              my $track_index = 0;
              map
              {
                my $title = $_;
                chomp($title);
                $track_index++;
                if ($title eq $track_info{'track'})
                {
                  $track_info{'track_no'} = $track_index;
                }
              } $album->tracks();
              $track_info{'track_count'} = $track_index;
            }
          } $aws_response->properties();
        }
        else
        {
          print "could not ask Amazon webservices for track-info\n";
          print $aws_response->message();
          exit(1);
        }
      }
  }
  # handle sockets
  map 
  {
    my $sock = $_;
    debug("reading from socket\n");
    if ($sock eq $handshake)
    {
      debug("reading login response\n");
      $sock->recv($buffer,1024,0) || $sockets->remove($sock);
      if($buffer =~ /session=([\w\d]+)/)
      {
        $session = $1;
      }
      if($buffer =~ /stream_url=(.+)\n/)
      {
        $stream_url = $1;
        $stream_url =~ /http:\/\/([\.\w\d]+)\//;
        $mp3_host = $1;
      }
      if($session eq 'FAILED')
      {
        die "could not login\n";
      }
    }
    elsif ($sock eq $tune)
    {
      debug("reading tune response\n");
      $sock->recv($buffer,1024,0) || $sockets->remove($sock);
      if($buffer =~ /HTTP\/1\.. 503/)
      {
        print "\nlast.fm service is temoprarily unavailable\n";
        exit(1);
      }
      if($buffer =~ /response=OK/)
      {
        $tuned = 1;
      }
      else
      {
        print "sorry, could not tune last.fm to play $url.\n";
        if ($artist) 
        {
          print "maybe artist \"$artist\" is unkown to last.fm?\n";
        }
        else
        {
          print "maybe url $url ist not valid?\n";
        }
        exit(1);
      }
    }
    elsif ($sock eq $mp3)
    {
      debug("reading mp3 data\n");
      $sock->recv($buffer,262144,0);
      if ($length = index($buffer,"SYNC",0) != -1 )
      {
        debug("found new track\n");
        print "\n";
        if ($track_info{'artist'} ne '')
        {
          my $mp3_file_name = "$track_info{'artist'}-$track_info{'album'}-$track_info{'track'}.mp3";
          my $mp3_path = $output_directory."/".$mp3_file_name;
          open(MP3,">$mp3_path");
          print MP3 $mp3_data,substr($buffer,0,$length);
          close MP3;
          if ($tag_mp3s)
          {
            my $mp3 = MP3::Tag->new($mp3_path);
            my $mp3_tag = $mp3->new_tag("ID3v1");
            $mp3_tag->title($track_info{'track'});
            $mp3_tag->artist($track_info{'artist'});
            $mp3_tag->album($track_info{'album'});
            $mp3_tag->year($track_info{'year'});
            $mp3_tag->track($track_info{'track_no'});
            $mp3_tag->write_tag();
            my $mp3v2_tag = $mp3->new_tag("ID3v2");
            $mp3v2_tag->add_frame("TALB",$track_info{'album'});
            $mp3v2_tag->add_frame("TIT2",$track_info{'track'});
            $mp3v2_tag->add_frame("TPE1",$track_info{'artist'});
            $mp3v2_tag->add_frame("TLEN",$track_info{'trackduration'});
            $mp3v2_tag->add_frame("TRSN","last.fm");
            $mp3v2_tag->add_frame("TRCK",$track_info{'track_no'});
            $mp3v2_tag->add_frame("TYER",$track_info{'year'});
            
            if ($get_covers)
            {
              open(COVER,"$cover_file");
              my $cover_data;
              while(<COVER>){$cover_data.=$_};
              $mp3v2_tag->add_frame("APIC", chr(0x0), "image/jpeg", chr(0x0), "Cover Image", $cover_data);
            }
            $mp3v2_tag->write_tag;
            $mp3->close();
          }
        }
        $new_track = 1;
        $mp3_data=substr($buffer,$length);
      }
      else
      {
        $mp3_data.=$buffer;
      }
    }
    elsif ($sock eq $track_info)
    {
      debug("reading track info data\n");
      $sock->recv($buffer,1024,0) || $sockets->remove($sock);
      $track_info_data .= $buffer;      
    }
  } $sockets->can_read(100);
  map
  {
    my $sock = $_;
    debug("writing to sockets\n");  
  } $sockets->can_write(100);
  print "\rDATA_LENGTH: ".length($mp3_data)."  TRACK: $track_info{'track'}  ARTIST: $track_info{'artist'}  ALBUM: $track_info{'album'}";
 if ($bye_bye){print "\nbye!\n";exit(0);}
}

sub debug
{
  if ($debug)
  {
    print @_;
  }
}

sub usage
{
  print <<EOF;
last.fm-ripper - $VERSION
(c) Copyright 2006 - Jochen Schneider - <scne59\@googlemail.com>

usage:
 
last.fm-ripper -u <username> [-p <password>] [-d] -[c] [-o <output-dir>] [-a <artist>] <lastfm-url>

  -u, --username          last.fm username
  -p, --password          last.fm password
  -a, --artist            artist name (to find similar titles), obsolets last.fm url
  -d, --debug             enable debugging output
  -o, --output_directory  where to save mp3-files
  -n, --nocovers          disable cover download
  -w, --aws_token         amazon webservices developer token for advanced tagging (http://amazon.com/soap)
      

EOF
print "\n";
}

sub guess_platform
{
  my $platform = lc(`uname -s`);
  chomp($platform);
  if($platform eq ''){$platform="windows"}; #guess we are on win if uname does not work
  return $platform;
}

sub md5_password
{
  my $password = shift @_;
  my $md5 = Digest::MD5->new();
  $md5->reset;
  $md5->add($password);
  return $md5->hexdigest;
}

=pod

=head1 NAME

last.fm-ripper - save last.fm radio to mp3 files

=head1 SYNOPSIS

last.fm-ripper -u <username> [-p <password>] [-d] -[c] [-o <output-dir>] [-a <artist>] <lastfm-url>

 -u, --username          last.fm username
 -p, --password          last.fm password
 -a, --artist            artist name (to find similar titles), obsolets last.fm url
 -d, --debug             enable debugging output
 -o, --output_directory  where to save mp3-files
 -n, --no_covers         disable cover download
 -w, --aws_token         amazon webservices developer token for advanced tagging (http://amazon.com/soap)

=head1 AUTHOR

Jochen Schneider, <scne59@googlemail.com>

=head1 DESCRIPTION

last.fm-ripper is a small utility to save the last.fm program to individual mp3-files including
all availabel id3-tags and the cover art (requires MP3::Tag module).
Requires a valid last.fm login and password (http://www.last.fm/signup.php).
If you don't like to enter your password on the commandline last.fm-ripper asks for it
(requires Term::ReadPassword not to be echoed to the terminal).

For advanced tagging (track-no., year) an amazon webservices developer token
is required (http://amazon.com/soap).

=head1 COPYRIGHT

Copyright (c) 2006 Jochen Schneider.  All rights reserved.

This program is free software; you can redistribute it and/or
modify it under the terms of the Artistic License, distributed
with Perl.

=cut