package Net::iTMS; # # Written by Thomas R. Sibley, # # Information on properly fetching the URLs and decrypting # the content thanks to Jason Rohrer. # use warnings; use strict; use vars '$VERSION'; $VERSION = '0.07'; use LWP::UserAgent; use HTTP::Request; use Crypt::CBC; use Crypt::Rijndael; use Digest::MD5; =head1 NAME Net::iTMS - Low(ish)-level interface to the iTunes Music Store (iTMS) =head1 SYNOPSIS use Net::iTMS; my $iTMS = Net::iTMS->new; my $results = $iTMS->search_for('Elliott Smith'); my %tracklist = %{$results->TrackList}; # See the Net::iTMS::XML doc for other methods =head1 DESCRIPTION Net::iTMS is a low-but-not-too-low-level interface to the iTunes Music Store. It handles the fetching, decrypting, and uncompressing of content as well as provides a few convenience methods. Further development will most likely include more convenience methods for common tasks. If there is a method you'd particularly like to see, contact me (see website in AUTHOR section) about it, and I'll consider writing it. Patches are welcome. : ) =head2 Methods All methods return C on error and (should) set an error message, which is available through the C method. (Unless I note otherwise.) B Most of information-fetching methods return, by default, a L object which can be used to selectively extract information from the XML. If a different XML "parser" is in use, the return value could be something totally different. =over 12 =item C<< new([ debug => 1, [...] ]) >> Takes an argument list of C value> pairs. The options available are: =over 24 =item C<< tmpdir => '/some/path' >> Used to specify the path to the directory where temporary files should be created. Default's to L's default. =item C<< debug => 0 or 1 >> If set to a true value, debug messages to be printed to STDERR. =item C<< parser => 'Foo::Bar' >> "Parser" to use in place of the default L. Don't change this unless you know what you're doing. =back Returns a blessed hashref (object) for Net::iTMS. =cut sub new { my ($class, %opt) = @_; my $ua = LWP::UserAgent->new; $ua->agent('iTunes/4.2 (Macintosh; U; PPC Mac OS X 10.2)'); my $parser = defined $opt{parser} ? $opt{parser} : 'Net::iTMS::XML'; eval qq{ require $parser }; die $@ if $@; return bless { error => '', debug => defined $opt{debug} ? $opt{debug} : 0, tmpdir => defined $opt{tmpdir} ? $opt{tmpdir} : undef, _parser => $parser, _ua => $ua, _url => { search => 'http://phobos.apple.com/WebObjects/MZSearch.woa/wa/com.apple.jingle.search.DirectAction/search?term=', viewAlbum => 'http://ax.phobos.apple.com.edgesuite.net/WebObjects/MZStore.woa/wa/viewAlbum?playlistId=', viewArtist => 'http://ax.phobos.apple.com.edgesuite.net/WebObjects/MZStore.woa/wa/viewArtist?artistId=', biography => 'http://ax.phobos.apple.com.edgesuite.net/WebObjects/MZStore.woa/wa/com.apple.jingle.app.store.DirectAction/biography?artistId=', influencers => 'http://ax.phobos.apple.com.edgesuite.net/WebObjects/MZStore.woa/wa/com.apple.jingle.app.store.DirectAction/influencers?artistId=', browseArtist => 'http://ax.phobos.apple.com.edgesuite.net/WebObjects/MZStore.woa/wa/com.apple.jingle.app.store.DirectAction/browseArtist?artistId=', }, }, $class; } =item C Does a simple search of the catalog. =cut sub search_for { my ($self, $query) = @_; # Decrypted, uncompressed XML is returned return $self->fetch_iTMS_info($self->_url('search') . $query); } =item C Takes an albumId and fetches the album information page. =cut sub get_album { my ($self, $id) = @_; return $id ? $self->fetch_iTMS_info($self->_url('viewAlbum') . $id) : $self->_set_error('No album ID passed.'); } =item C Takes an artistId and fetches the artist information page. =cut sub get_artist { my ($self, $id) = @_; return $id ? $self->fetch_iTMS_info($self->_url('viewArtist') . $id) : $self->_set_error('No artist ID passed.'); } =item C Takes an artistId and fetches the artist's iTMS biography, if there is one. =cut sub get_biography { my ($self, $id) = @_; return $id ? $self->fetch_iTMS_info($self->_url('biography') . $id) : $self->_set_error('No artist ID passed.'); } =item C Takes an artistId and fetches the artist's iTMS influencers, if there are any. =cut sub get_influencers { my ($self, $id) = @_; return $id ? $self->fetch_iTMS_info($self->_url('influencers') . $id) : $self->_set_error('No artist ID passed.'); } =item C Takes an artistId and fetches all the albums (really a browseArtist request). =cut sub get_artist_albums { my ($self, $id) = @_; return $id ? $self->fetch_iTMS_info($self->_url('browseArtist') . $id) : $self->_set_error('No artist ID passed.'); } =item C<< fetch_iTMS_info($url, [ gunzip => 1, decrypt => 0 ]) >> This is one of the lower-level methods used mostly internally for convenience. Still, it might be of use to implement something I haven't thought of. It takes a URL (that should be for the iTMS) as the first argument and an optional hashref of options as the second argument. The available options are: =over 24 =item C<< gunzip => 0 or 1 >> A true value means the (presumably) gzipped content is gunzipped. A false value means it is not. Default is 1 (unzip content). =item C<< decrypt => 0, 1, or 2 >> A true value other than 2 means the content retrieved from the URL is first decrypted after fetching if it appears to be encrypted (that is, if no initialization vector was passed as a response header for the request). A false value means no decryption is done at all. A value of 2 means decryption will be forced no matter what. Default is 1 ("intelligent" decrypt), which should work for most, if not all, cases. =back =cut sub fetch_iTMS_info { my ($self, $url) = @_; my $opt = defined $_[2] ? $_[2] : { }; my $xml = $self->_fetch_iTMS_data($url, $opt) or return undef; $self->_debug($xml); $self->_debug("Parsing $url"); return $self->{_parser}->new($xml)->parse || $self->_set_error('Error parsing XML!'); } =item C Returns a string containing an error message (if there is one). Usually useful after a method has returned C for finding out what went wrong. =cut sub error { my $self = shift; return $self->{error}; } sub _fetch_iTMS_data { my ($self, $url, $userOpt) = @_; return $self->_set_error('No URL specified!') if not $url; my $opt = { gunzip => 1, decrypt => 1 }; if (defined $userOpt) { for (qw/gunzip decrypt/) { $opt->{$_} = $userOpt->{$_} if exists $userOpt->{$_}; } } $self->_debug('Sending HTTP request...'); # Create and send request my $req = HTTP::Request->new(GET => $url); $self->_set_request_headers($req); my $res = $self->{_ua}->request($req); if (not $res->is_success) { return $self->_set_error('HTTP request failed!' . "\n\n" . $req->as_string); } $self->_debug('Successful request!'); if ($opt->{decrypt}) { $self->_debug('Decrypting content...'); # Since the key is static, we can just hard-code it here my $iTunesKey = pack 'H*', '8a9dad399fb014c131be611820d78895'; # # Create the AES CBC decryption object using the iTunes key and the # initialization vector (x-apple-crypto-iv) # my $cbc = Crypt::CBC->new({ key => $iTunesKey, cipher => 'Rijndael', iv => pack ('H*', $res->header('x-apple-crypto-iv')), regenerate_key => 0, padding => 'standard', prepend_iv => 0, }); # Try to intelligently determine whether content is actually # encrypted. If it isn't, skip the decryption unless the caller # explicitly wants us to decrypt (the decrypt option = 2). my $decrypted; if ($opt->{decrypt} == 2 or $res->header('x-apple-crypto-iv')) { $decrypted = $cbc->decrypt($res->content); } else { $self->_debug(' Content looks unencrypted... skipping decryption'); $decrypted = $res->content; } if ($opt->{gunzip}) { $self->_debug('Uncompressing content...'); return $self->_gunzip_data($decrypted); } else { return $decrypted; } } elsif ($opt->{gunzip}) { $self->_debug('foo'); $self->_debug('Uncompressing content...'); return $self->_gunzip_data($res->content); } else { return $res->content; } } sub _gunzip_data { my ($self, $data) = @_; # Write gzipped data to temporary file my $template = 'net-itms.XXXXXXXXX'; my $dir = defined $self->{tmpdir} ? $self->{tmpdir} : '.'; use File::MkTemp qw(mkstempt); $self->_debug('Writing gzipped data to temp file...'); my ($fh, $fname) = mkstempt($template, $dir); print $fh $data; $fh->close; # Use Compress::Zlib to decompress it use Compress::Zlib qw(gzopen Z_STREAM_END); my $gz = gzopen("$dir/$fname", 'rb') or return $self->_set_error('Open of _gunzip_data tmpfile failed!'); my ($xml, $buffer); $xml .= $buffer while $gz->gzread($buffer) > 0; if ($gz->gzerror != Z_STREAM_END) { return $self->_set_error('Error while uncompressing gzipped data: ', $gz->gzerror); } $gz->gzclose; $self->_debug('Removing tmpfile...'); unlink "$dir/$fname"; return $xml; } sub _set_request_headers { my $req = $_[1]; $req->header('Accept-Language' => 'en-us, en;q=0.50'); $req->header('Cookie' => 'countryVerified=1'); $req->header('Accept-Encoding' => 'gzip, x-aes-cbc'); } sub _url { my ($self, $url) = @_; return defined $self->{_url}->{$url} ? $self->{_url}->{$url} : $self->_set_error('No URL found!'); } sub _debug { my $self = shift; print STDERR @_, "\n" if $self->{debug}; return 1; } sub _set_error { my $self = shift; $self->{error} = join '', @_; $self->_debug($self->{error}); return undef; } =back =head1 LICENSE Copyright 2004, Thomas R. Sibley. This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike License. To view a copy of this license, visit L or send a letter to: Creative Commons 559 Nathan Abbott Way Stanford, California 94305, USA. =head1 AUTHOR Thomas R. Sibley, L =cut 42;