File Coverage

blib/lib/Net/DAAP/Client.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1 1     1   919 use strict;
  1         2  
  1         47  
2             package Net::DAAP::Client;
3 1     1   522 use Net::DAAP::Client::v2;
  1         2  
  1         28  
4 1     1   528 use Net::DAAP::Client::v3;
  1         3  
  1         36  
5 1     1   518 use Net::DAAP::DMAP 1.22;
  0            
  0            
6             use Net::DAAP::DMAP qw(:all);
7             use LWP;
8             use HTTP::Request::Common;
9             use Carp;
10             use sigtrap qw(die untrapped normal-signals);
11             use vars qw( $VERSION );
12             $VERSION = '0.42';
13              
14             =head1 NAME
15              
16             Net::DAAP::Client - client for Apple iTunes DAAP service
17              
18             =head1 SYNOPSIS
19              
20             my $daap; # see WARNING below
21             $daap = Net::DAAP::Client->new(SERVER_HOST => $hostname,
22             SERVER_PORT => $portnum,
23             PASSWORD => $password);
24             $dsn = $daap->connect;
25              
26             $dbs_hash = $daap->databases;
27             $current_db = $daap->db;
28             $daap_db($new_db_id);
29              
30             $songs_hash = $daap->songs;
31             $playlists_hash = $daap->playlists;
32             $array_of_songs_in_playlist = $daap->playlist($playlist_id);
33              
34             $url = $daap->url($song_or_playlist_id);
35              
36             $binary_audio_data = $obj->get($song_id);
37             $binary_audio_data = $obj->get(@song_ids);
38             $song_id = $obj->save($dir, $song_id);
39             @song_ids = $obj->get($dir, @song_ids);
40              
41             $daap->disconnect;
42              
43             if ($daap->error) {
44             warn $daap->error; # returns error string
45             }
46              
47             =head1 DESCRIPTION
48              
49             Net::DAAP::Client provides objects representing connections to DAAP
50             servers. You can fetch databases, playlists, and songs. This module
51             was written based on a reverse engineering of Apple's iTunes 4 sharing
52             implementation. As a result, features that iTunes 4 doesn't support
53             (browsing, searching) aren't supported here.
54              
55             Each connection object has a destructor, so that you can forget to
56             C without leaving the server expecting you to call back.
57              
58             =head2 WARNING
59              
60             If you store your object in a global variable, Perl can't seem to
61             disconnect gracefully from the server. Until I figure out why, always
62             store your object in a lexical (C) variable.
63              
64             =head1 METHODS
65              
66             =cut
67              
68             my $DAAP_Port = 3689;
69             my @User_Columns = qw( SERVER_HOST SERVER_PORT PASSWORD DEBUG SONG_ATTRIBUTES );
70             my %Defaults = (
71             # user-specified
72             SERVER_HOST => "",
73             SERVER_PORT => $DAAP_Port,
74             PASSWORD => "",
75             DEBUG => 0,
76             SONG_ATTRIBUTES => [ qw(dmap.itemid dmap.itemname dmap.persistentid
77             daap.songalbum daap.songartist daap.songformat
78             daap.songsize) ],
79              
80             # private
81             ERROR => "",
82             CONNECTED => 0,
83             DATABASE_LIST => undef,
84             DATABASE => undef,
85             SONGS => undef,
86             PLAYLISTS => undef,
87             VALIDATOR => undef,
88             );
89              
90              
91             sub new {
92             my $class = shift;
93             my $self = bless { %Defaults } => $class;
94              
95             if (@_ > 1) {
96             $self->_init(@_);
97             } elsif (@_) {
98             $self->{SERVER_HOST} = shift;
99             } else {
100             warn "Why are you calling new with no arguments?";
101             die "Need to implement get/set for hostname and port";
102             }
103              
104             return $self;
105             }
106              
107             =head2 * new()
108              
109             $obj = Net::DAAP::Client->new(OPTNAME => $value, ...);
110              
111             The allowed options are:
112              
113             =over 4
114              
115             =item SERVER_NAME
116              
117             The hostname or IP address of the server.
118              
119             =item SERVER_PORT
120              
121             The port number of the server.
122              
123             =item PASSWORD
124              
125             The password to use when authenticating.
126              
127             =item DEBUG
128              
129             Print some debugging output
130              
131              
132             =item SONG_ATTRIBUTES
133              
134             The attributes to retrieve for a song as an array reference. The
135             default list is:
136              
137             [qw( dmap.itemid dmap.itemname dmap.persistentid daap.songalbum
138             daap.songartist daap.songformat daap.songsize )]
139              
140             =back
141              
142             =cut
143              
144             sub _init {
145             my $self = shift;
146             my %opts = @_;
147              
148             foreach my $key (@User_Columns) {
149             $self->{$key} = $opts{$key} || $Defaults{$key};
150             }
151             }
152              
153             sub _debug {
154             my $self = shift;
155             warn "$_[0]\n" if $self->{DEBUG};
156             }
157              
158             =head2 * connect()
159              
160             $name = $obj->connect
161             or die $obj->error;
162              
163             Attempts to fetch the server information, log in, and learn the latest
164             revision number. It returns the name of the server we've connected to
165             (as that server reported it). It returns C if any of the steps
166             fail. If it fails fetching the revision number, it logs out before
167             returning C.
168              
169             =cut
170              
171              
172             sub connect {
173             my $self = shift;
174             my $ua = ($self->{UA} ||= Net::DAAP::Client::UA->new(keep_alive => 1) );
175             my ($dmap, $id);
176              
177             $self->_devine_validator;
178              
179              
180             $self->error("");
181             $self->{DATABASE_LIST} = undef;
182              
183             # get content codes
184             $dmap = $self->_do_get("content-codes") or return;
185             update_content_codes(dmap_unpack($dmap));
186              
187             # check server name/version
188             $dmap = $self->_do_get("server-info") or return;
189              
190             my %hash = dmap_flat_list( dmap_unpack ($dmap) );
191             my $data_source_name = $hash{'/dmap.serverinforesponse/dmap.itemname'};
192             $self->{DSN} = $data_source_name;
193             $self->_debug("Connected to iTunes share '$data_source_name'");
194              
195             # log in
196             $dmap = $self->_do_get("login") or return;
197             $id = dmap_seek(dmap_unpack($dmap), "dmap.loginresponse/dmap.sessionid");
198             $self->{ID} = $id;
199             $self->_debug("my id is $id");
200              
201             $self->{CONNECTED} = 1;
202              
203             # fetch databases
204             my $dbs = $self->databases()
205             or return;
206              
207             # autoselect if only one database present
208             if (keys(%$dbs) == 1) {
209             $self->db((keys %$dbs)[0])
210             or return;
211             }
212              
213             return $self->{DSN};
214             }
215              
216             =head2 * databases()
217              
218             $dbs = $self->databases();
219              
220             Returns a hash reference. Sample:
221              
222             =cut
223              
224             sub databases {
225             my $self = shift;
226              
227             $self->error("");
228              
229             unless ($self->{CONNECTED}) {
230             $self->error("Not connected--can't fetch databases list");
231             return;
232             }
233              
234             my $res = $self->_do_get("databases");
235             my $listing = dmap_seek(dmap_unpack($res),
236             "daap.serverdatabases/dmap.listing");
237              
238             unless ($listing) {
239             $self->error("databases query didn't return a list of databases");
240             return;
241             }
242              
243             my $struct = $self->_unpack_listing_to_hash($listing);
244              
245             $self->{DATABASE_LIST} = $struct;
246             return $struct;
247             }
248              
249             =head2 * db()
250              
251             $db_id = $obj->db; # learn current database ID
252             $obj->db($db_id); # set current database
253              
254             A database ID is a key from the hash returned by
255             C<< $obj->databases >>.
256              
257             Setting the database loads the playlists and song list for that
258             database. This can take some time if there are a lot of songs in
259             either list.
260              
261             This method returns true if an error occurred, false otherwise.
262             If an error occurs, you can't rely on the song list or play list
263             having been loaded.
264              
265             =cut
266              
267             sub db {
268             my ($self, $db_id) = @_;
269             my $db;
270              
271             unless ($self->{DATABASE_LIST}) {
272             $self->error("You haven't fetched the list of databases yet");
273             return;
274             }
275              
276             unless (defined $db_id) {
277             return $self->{DATABASE};
278             }
279              
280             $db = $self->{DATABASE_LIST}{$db_id};
281             if (defined $db) {
282             $self->{DATABASE} = $db_id;
283             $self->_debug("Loading songs from database $db->{'dmap.itemname'}");
284             $self->{SONGS} = $self->_get_songs($db_id)
285             or return;
286             $self->_debug("Loading playlists from database $db->{'dmap.itemname'}");
287             $self->{PLAYLISTS} = $self->_get_playlists($db_id)
288             or return;
289             } else {
290             $self->error("Database ID $db_id not found");
291             return;
292             }
293              
294             return $self;
295             }
296              
297             =head2 * songs()
298              
299             $songs = $obj->songs();
300              
301             Returns a hash reference. Keys are song IDs, values are hashes with
302             information on the song. Information fetched is specified by
303             SONG_ATTRIBUTES, the default set is:
304              
305             =over
306              
307             =item dmap.itemid
308              
309             Unique ID for the song.
310              
311             =item dmap.itemname
312              
313             Title of the track.
314              
315             =item dmap.persistentid
316              
317             XXX [add useful explanation here]
318              
319             =item daap.songalbum
320              
321             Album name that the track came from.
322              
323             =item daap.songartist
324              
325             Artist who recorded the track.
326              
327             =item daap.songformat
328              
329             A string, "mp3", "aiff", etc.
330              
331             =item daap.songsize
332              
333             Size in bytes of the file.
334              
335             =back
336              
337             A sample record:
338              
339             '127' => {
340             'daap.songsize' => 2597221,
341             'daap.songalbum' => 'Live (Disc 2)',
342             'dmap.persistentid' => '4081440092921832180',
343             'dmap.itemname' => 'Down To The River To Pray',
344             'daap.songartist' => 'Alison Krauss + Union Station',
345             'dmap.itemid' => 127,
346             'daap.songformat' => 'mp3'
347             },
348              
349             To find out what other attributes you can request consult the DAAP
350             spec at http://tapjam.net/daap/draft.html
351              
352             =cut
353              
354             sub songs {
355             my $self = shift;
356              
357             return $self->{SONGS};
358             }
359              
360             =head2 * playlists()
361              
362             $songlist = $obj->playlists();
363              
364             Returns a hash reference. Keys are playlist IDs, values are hashes
365             with information on the playlist.
366              
367             XXX: explain keys
368              
369             A sample record:
370              
371             '2583' => {
372             'dmap.itemcount' => 335,
373             'dmap.persistentid' => '4609413108325671202',
374             'dmap.itemname' => 'Recently Played',
375             'com.apple.itunes.smart-playlist' => 0,
376             'dmap.itemid' => 2583
377             }
378              
379             =cut
380              
381             sub playlists {
382             my $self = shift;
383              
384             return $self->{PLAYLISTS};
385             }
386              
387             sub _get_songs {
388             my ($self, $db_id) = @_;
389              
390             my $path = "databases/$db_id/items?type=music&meta=" .
391             join ",", @{ $self->{SONG_ATTRIBUTES} };
392             my $res = $self->_do_get($path) or return;
393              
394             my $listing = dmap_seek(dmap_unpack($res),
395             "daap.databasesongs/dmap.listing");
396             if (!$listing) {
397             $self->error("no song database in response from server");
398             return;
399             }
400              
401             my $struct = $self->_unpack_listing_to_hash($listing);
402             delete @{%$struct}{ grep { $struct->{$_}{'daap.songsize'} == 0 } keys %$struct }; # remove deleted songs
403              
404             return $struct;
405             }
406              
407             sub _get_playlists {
408             my ($self, $db_id) = @_;
409              
410             my $res = $self->_do_get("databases/$db_id/containers?meta=dmap.itemid,dmap.itemname,dmap.persistentid,com.apple.itunes.smart-playlist")
411             or return;
412              
413             my $listing = dmap_seek(dmap_unpack($res),
414             "daap.databaseplaylists/dmap.listing");
415             if (!$listing) {
416             $self->error("no playlist in response from server");
417             return;
418             }
419              
420             return $self->_unpack_listing_to_hash($listing);
421             }
422              
423             =head2 * playlist
424              
425             $playlist = $obj->playlist($playlist_id);
426              
427             A playlist ID is a key from the hash returned from the C
428             method. Returns an array of song records.
429              
430             =cut
431              
432             sub playlist {
433             my ($self, $playlist_id) = @_;
434              
435             my $db_id = $self->{DATABASE};
436             if (!$db_id) {
437             $self->error("No database selected so can't fetch playlist");
438             return;
439             }
440              
441             if (!exists $self->{PLAYLISTS}->{$playlist_id}) {
442             $self->error("No such playlist $playlist_id");
443             return;
444             }
445              
446             my $res = $self->_do_get("databases/$db_id/containers/$playlist_id/items?type=music&meta=dmap.itemkind,dmap.itemid,dmap.containeritemid")
447             or return;
448              
449             my $listing = dmap_seek(dmap_unpack($res),
450             "daap.playlistsongs/dmap.listing");
451             if (!$listing) {
452             $self->error("Couldn't fetch playlist $playlist_id");
453             }
454              
455             my $struct = [];
456              
457             foreach my $item (@$listing) {
458             my $record = {};
459             my $field_array_ref = $item->[1];
460             foreach my $field_pair_ref (@$field_array_ref) {
461             my ($field, $value) = @$field_pair_ref;
462             $record->{$field} = $value;
463             }
464             push @$struct, $self->{SONGS}->{ $record->{"dmap.itemid"} };
465             }
466              
467             return $struct;
468             }
469              
470             sub _unpack_listing_to_hash {
471             my ($self, $listing) = @_;
472              
473             my $struct = {};
474              
475             foreach my $item (@$listing) {
476             my $record = {};
477             my $field_array_ref = $item->[1];
478             foreach my $field_pair_ref (@$field_array_ref) {
479             my ($field, $value) = @$field_pair_ref;
480             $record->{$field} = $value;
481             }
482             $struct->{$record->{'dmap.itemid'}} = $record;
483             }
484              
485             return $struct;
486             }
487              
488             =head2 * url
489              
490             $url = $obj->url($song_id);
491             $url = $obj->url($playlist_id);
492              
493             Returns the persistent URL for the track or playlist.
494              
495             =cut
496              
497             ###
498             ### XXX: I go from Math::BigInt to
499             ### string to Math::BigInt again. Some of these helper methods are surely
500             ### not necessary?
501             ###
502              
503             sub url {
504             my ($self, @arg) = @_;
505              
506             $self->error("");
507              
508             if (!$self->{CONNECTED}) {
509             $self->error("Can't fetch URL when not connected");
510             return;
511             }
512              
513             my $song_list = $self->{SONGS};
514             my $playlists = $self->{PLAYLISTS};
515             my $db = $self->{DATABASE_LIST}{$self->{DATABASE}}{"dmap.persistentid"};
516             my @urls = ();
517             my @skipped = ();
518              
519             foreach my $id (@arg) {
520             if (exists $song_list->{$id}) {
521             my $song = $song_list->{$id};
522             push @urls, $self->
523             _build_resolve_url(database => $db,
524             song => $song->{"dmap.persistentid"});
525             } elsif (exists $playlists->{$id}) {
526             my $playlist = $playlists->{$id};
527             push @urls, $self->
528             _build_resolve_url(database => $db,
529             playlist => $playlist->{"dmap.persistentid"});
530             } else {
531             push @skipped, $id;
532             }
533             }
534              
535             if (@skipped) {
536             $self->error("skipped: @skipped");
537             }
538              
539             if (wantarray) {
540             return @urls;
541             } else {
542             return $urls[0];
543             }
544             }
545              
546             sub _build_resolve_url {
547             my ($self, %specs) = @_;
548              
549             return "daap://$self->{SERVER_HOST}:$self->{SERVER_PORT}/resolve?" .
550             join('&', map {my $id = $self->_persistentid_as_text($specs{$_});
551             "$_-spec='dmap.persistentid:$id'"} keys %specs);
552             }
553              
554             sub _persistentid_as_text {
555             my ($self, $id) = @_;
556              
557             $id = new Math::BigInt($id);
558              
559             return sprintf("0x%08x%08x", $id->brsft(32), $id->band(0xffffffff));
560             }
561              
562              
563             =head2 * get
564              
565             @tracks = $obj->get(@song_ids);
566              
567             Returns the binary data of the song. A song ID is a key from
568             the hash returned by C, or the C from one of
569             the elements in the array returned by C.
570              
571             =cut
572              
573             sub get {
574             my ($self, @arg) = @_;
575             $self->_download_songs(undef, @arg);
576             }
577              
578             sub _download_songs {
579             my ($self, $dir, @arg) = @_;
580             my $song_list = $self->{SONGS};
581             my @songs;
582             my @skipped;
583              
584             foreach my $song_id (@arg) {
585             my $song = $song_list->{$song_id};
586              
587             if (!defined $song) { # ok to blur defined() and exists() here
588             push @skipped, $song_id;
589             next;
590             }
591             my $response = $self->_get_song($self->{DATABASE}, $song, $dir);
592             if (!$response) {
593             push @skipped, $song_id;
594             } else {
595             push @songs, $dir ? $song_id : $response;
596             }
597             }
598              
599             if (@skipped) {
600             $self->error("skipped: @skipped");
601             }
602             if (wantarray) {
603             return @songs;
604             } else {
605             return $songs[0];
606             }
607             }
608              
609             sub _get_song {
610             my ($self, $db_id, $song, $dir) = @_;
611             my ($song_id, $format) =
612             ($song->{"dmap.itemid"}, $song->{"daap.songformat"});
613             my $filename = "$song_id.$format";
614              
615             ++$self->{REQUEST_ID};
616              
617             if ($dir) {
618             return $self->_do_get("databases/$db_id/items/$filename",
619             "$dir/$filename");
620             } else {
621             return $self->_do_get("databases/$db_id/items/$filename");
622             }
623             }
624              
625             =head2 * save
626              
627             $tracks_saved = $obj->save($dir, @song_ids);
628              
629             Saves the binary data of the song to the directory. Returns the
630             number of songs saved.
631              
632             =cut
633              
634             sub save {
635             my ($self, @arg) = @_;
636             $self->_download_songs(@arg);
637             }
638              
639             =head2 * disconnect()
640              
641             $obj->disconnect;
642              
643             Logs out of the database. Returns C if an error occurred, a
644             true value otherwise. If an error does occur, there's probably not
645             much you can do about it.
646              
647             =cut
648              
649             sub disconnect {
650             my $self = shift;
651              
652             $self->error("");
653             if ($self->{CONNECTED}) {
654             (undef) = $self->_do_get("logout");
655             }
656             undef $self->{CONNECTED};
657             return $self->error;
658             }
659              
660             sub DESTROY {
661             my $self = shift;
662             $self->_debug("Destroying $self->{ID} to $self->{SERVER_HOST}");
663             $self->disconnect;
664             }
665              
666             =head2 * error()
667              
668             $string = $obj->error;
669              
670             Returns the most recent error code. Empty string if no error occurred.
671              
672             =cut
673              
674             sub error {
675             my $self = shift;
676             if ($self->{DEBUG} and defined($_[0]) and length($_[0])) {
677             warn "Setting error to $_[0]\n";
678             }
679             if (@_) { $self->{ERROR} = shift } else { $self->{ERROR} }
680             }
681              
682             sub _devine_validator {
683             my $self = shift;
684             $self->{VALIDATOR} = undef;
685             $self->{M4p_evil} = 0;
686              
687             my $response = $self->{UA}->get( $self->_server_url.'/server-info' );
688             my $server = $response->header('DAAP-Server');
689              
690             if ($server =~ m{^iTunes/4.2 }) {
691             $self->{VALIDATOR} = __PACKAGE__."::v2";
692             return;
693             }
694              
695             if ($server =~ m{^iTunes/}) {
696             $self->{M4p_evil} = 1;
697             $self->{VALIDATOR} = __PACKAGE__."::v3"
698             }
699             }
700              
701              
702             sub _validation_cookie {
703             my $self = shift;
704             return unless $self->{VALIDATOR};
705             return ( "Client-DAAP-Validation" => $self->{VALIDATOR}->validate( @_ ) );
706             }
707              
708             sub _server_url {
709             my $self = shift;
710             sprintf("http://%s:%d", $self->{SERVER_HOST}, $self->{SERVER_PORT});
711             }
712              
713             # quite the fugly hack
714             my @credentials;
715             {
716             package Net::DAAP::Client::UA;
717             use base qw( LWP::UserAgent );
718             sub get_basic_credentials { return @credentials }
719              
720             }
721              
722             sub _do_get {
723             my ($self, $req, $file) = @_;
724             if (!defined wantarray) { carp "_do_get's result is being ignored" }
725              
726             my $id = $self->{ID};
727             my $revision = $self->{REVISION};
728             my $ua = $self->{UA};
729              
730             my $url = $self->_server_url . "/$req";
731             my $res;
732              
733             # append session-id and revision-number query args automatically
734             if ($self->{ID}) {
735             $url .= $req =~ m{ \? }x ? "&" : "?";
736             $url .= "session-id=$id";
737             }
738              
739             if ($revision && $req ne 'logout') {
740             $url .= "&revision-number=$revision";
741             }
742              
743             # fetch into memory or save to disk as needed
744              
745             $self->_debug($url);
746              
747             # form the request ourself so we have magic headers.
748             my $path = $url;
749             $path =~ s{http://.*?/}{/};
750              
751             my $reqid = $self->{REQUEST_ID};
752             my $request = HTTP::Request::Common::GET(
753             $url,
754             "Client-DAAP-Version" => '3.0',
755             "Client-DAAP-Access-Index" => 2,
756             $reqid ? ( "Client-DAAP-Request-ID" => $reqid ) : (),
757             $self->_validation_cookie( $path, 2, $reqid ),
758             );
759              
760             #print ">>>>\n", $request->as_string, ">>>>>\n";
761              
762             # It would seem that 4.{5,6} are using their internal MD5/M4p for
763             # their digest auth, or some other form of evil, certainly the
764             # regular Digest auth that works with 4.2 gets refused.
765              
766             #local *Digest::MD5::new = sub { shift; Digest::MD5::M4p->new( @_ ) }
767             # if $self->{M4p_evil};
768              
769             @credentials = $self->{PASSWORD} ? ('iTunes_4.6', $self->{PASSWORD}) : ();
770              
771             if ($file) {
772             $res = $ua->request($request, $file);
773             } else {
774             $res = $ua->request($request);
775             }
776             # complain if the server sent back the wrong response
777             unless ($res->is_success) {
778             $self->error("$url\n" . $res->as_string);
779             return;
780             }
781              
782             my $content_type = $res->header("Content-Type");
783             if ($req !~ m{(?:/items/\d+\.|logout)} && $content_type !~ /dmap/) {
784             $self->error("Broken response (content type $content_type) on $url");
785             return;
786             }
787              
788             if ($file) {
789             return $res; # return obj to avoid copying huge string
790             } else {
791             return $res->content;
792             }
793             }
794              
795             1;
796              
797             __END__