File Coverage

blib/lib/XML/XSPF.pm
Criterion Covered Total %
statement 19 21 90.4
branch n/a
condition n/a
subroutine 7 7 100.0
pod n/a
total 26 28 92.8


line stmt bran cond sub pod time code
1             package XML::XSPF;
2              
3             # $Id$
4              
5 6     6   6483860 use strict;
  6         12  
  6         276  
6 6     6   34 use base qw(XML::XSPF::Base);
  6         15  
  6         8630  
7              
8 6     6   52 use Carp;
  6         9  
  6         622  
9 6     6   5996 use Date::Parse;
  6         124742  
  6         793  
10 6     6   5556 use HTML::Entities;
  6         14520402  
  6         837  
11 6     6   6466 use POSIX qw(strftime);
  6         21141446  
  6         61  
12 6     6   10879 use XML::Parser;
  0            
  0            
13             use XML::Writer;
14              
15             use XML::XSPF::Track;
16              
17             our $VERSION = '0.7.1';
18              
19             our %defaults = (
20             'version' => 1,
21             'xmlns' => 'http://xspf.org/ns/0/',
22             'title' => 'gone with the schwinn',
23             'creator' => 'kermit the frog',
24             );
25              
26             my @singleValueElements = qw(
27             annotation album creator date duration identifier
28             image info license location title trackNum
29             );
30              
31             my %uriElements = (
32              
33             'playlist' => {
34             'identifier' => 1,
35             'image' => 1,
36             'info' => 1,
37             'license' => 1,
38             'link' => 1,
39             'location' => 1,
40             'meta' => 1,
41             },
42              
43             'track' => {
44             'identifier' => 1,
45             'image' => 1,
46             'info' => 1,
47             'link' => 1,
48             'location' => 1,
49             'meta' => 1,
50             }
51             );
52              
53             {
54             my $class = __PACKAGE__;
55              
56             # Public Methods
57             $class->mk_accessors(qw(
58             version charset xmlns title creator annotation location identifier
59             info image date license attributions links metas extensions trackList
60             ));
61             }
62              
63             sub parse {
64             my ($class, $handle) = @_;
65              
66             my $parser = XML::Parser->new(
67             'ErrorContext' => 2,
68             'ProtocolEncoding' => 'UTF-8',
69             'NoExpand' => 1,
70             'NoLWP' => 1,
71             'Handlers' => {
72             'Start' => \&handleStartElement,
73             'Char' => \&handleCharElement,
74             'End' => \&handleEndElement,
75             },
76             );
77              
78             # Stuff instance data needed for parsing the XSPF playlist into the parser object.
79             # There's no better way to do this and not have global variables, as
80             # Perl5 doesn't have a real 'self' or 'this' object.
81             my $self = $class->new;
82              
83             $parser->{'_xspf'} = {
84             'path' => undef,
85             'self' => $self,
86             'states' => [],
87             'track' => undef,
88             'tracks' => [],
89             };
90              
91             # Handle scalars, scalar refs, filehandles, IO::File, etc.
92             if (ref($handle) eq 'SCALAR') {
93              
94             eval { $parser->parse($$handle) };
95              
96             } elsif (!ref($handle) && -f $handle) {
97              
98             eval { $parser->parsefile($handle) };
99              
100             } else {
101              
102             eval { $parser->parse($handle) };
103             }
104              
105             if ($@) {
106             Carp::confess("Error while parsing playlist: [$@]\n");
107             return undef;
108             }
109              
110             # Playlists MUST have a element (even if it's empty version 1)
111             if (!$parser->{'_xspf'}->{'trackListCount'}) {
112              
113             Carp::confess("Error while parsing playlist - no trackList element!\n");
114             return undef;
115             }
116              
117             $parser = undef;
118              
119             return $self;
120             }
121              
122             # Create a XSPF document from our in-memory version.
123             sub toString {
124             my $self = shift;
125              
126             my $string = undef;
127              
128             my $writer = XML::Writer->new(
129             'OUTPUT' => \$string,
130             'DATA_MODE' => 1,
131             'DATA_INDENT' => 4,
132             );
133              
134             $writer->xmlDecl("UTF-8");
135              
136             $writer->startTag('playlist', 'version' => $self->version, 'xmlns' => $self->xmlns);
137              
138             for my $element (qw(title creator annotation info location identifier image date license)) {
139              
140             if (my $value = $self->$element) {
141              
142             $writer->dataElement($element, $value);
143             }
144             }
145              
146             if ($self->attributions) {
147              
148             $writer->startTag('attribution');
149              
150             for my $attribution ($self->attributions) {
151              
152             $writer->dataElement(@{$attribution});
153             }
154              
155             $writer->endTag('attribution');
156             }
157              
158             if ($self->trackList) {
159              
160             $writer->startTag('trackList');
161              
162             for my $track ($self->trackList) {
163              
164             $writer->startTag('track');
165              
166             for my $element (qw(location identifier)) {
167              
168             for my $cdata (@{$track->get("${element}s")}) {
169              
170             $writer->dataElement($element, $cdata);
171             }
172             }
173              
174             for my $element (qw(link meta)) {
175              
176             for my $cdata (@{$track->get("${element}s")}) {
177              
178             $writer->startTag($element, 'rel' => $cdata->[0]);
179             $writer->characters($cdata->[1]);
180             $writer->endTag($element);
181             }
182             }
183              
184             for my $element (qw(title creator annotation info image album trackNum duration)) {
185              
186             if (my $value = $track->$element) {
187              
188             $writer->dataElement($element, $value);
189             }
190             }
191              
192             $writer->endTag('track');
193             }
194              
195             $writer->endTag('trackList');
196             }
197              
198             $writer->endTag('playlist');
199             $writer->end;
200              
201             # Don't escape these. XML::Writer provides some basic escaping, but not all.
202             # http://rt.cpan.org/Ticket/Display.html?id=36778
203             # $string = encode_entities($string, '^\n\r\t !\#\$%\(-;=?-~<>&"');
204              
205             return $string;
206             }
207              
208             sub handleStartElement {
209             my ($parser, $element, %attributes) = @_;
210              
211             # Poor Man's HTML checker - XML::Parser treats elements.
212             # So look at the previous element, and if we're a single value
213             # element, fail.
214             if ($parser->{'_xspf'}->{'path'}) {
215              
216             my @parts = split(/\//, $parser->{'_xspf'}->{'path'});
217             my $last = pop @parts;
218              
219             if (grep { /^$last$/ } @singleValueElements) {
220              
221             Carp::confess("Found HTML markup in <$last>\n");
222             }
223             }
224              
225             my $path = $parser->{'_xspf'}->{'path'} .= "/$element";
226             my $self = $parser->{'_xspf'}->{'self'};
227              
228             push @{ $parser->{'_xspf'}->{'states'} }, {
229             'attributes' => \%attributes,
230             'cdata' => '',
231             'path' => $path,
232             };
233              
234             # Set some default types once we encounter them.
235             if ($path eq '/playlist/attribution') {
236              
237             if ($parser->{'_xspf'}->{'attributionCount'}) {
238              
239             Carp::confess("Too many attribution elements in playlist!\n");
240             }
241              
242             $self->set('attributions', []);
243              
244             $parser->{'_xspf'}->{'attributionCount'} = 1;
245             }
246              
247             if ($path eq '/playlist/meta' ||
248             $path eq '/playlist/link') {
249              
250             $self->set("${element}s", []);
251             }
252              
253             if ($path eq '/playlist/trackList') {
254              
255             if ($parser->{'_xspf'}->{'trackListCount'}) {
256              
257             Carp::confess("Too many trackList elements in playlist!\n");
258             }
259              
260             $parser->{'_xspf'}->{'trackListCount'} = 1;
261             }
262              
263             # We got a track entry - create a new object for it
264             if ($path eq '/playlist/trackList/track') {
265              
266             $parser->{'_xspf'}->{'track'} = XML::XSPF::Track->new;
267             }
268             }
269              
270             sub handleCharElement {
271             my ($parser, $value) = @_;
272              
273             # Keep the our little state machine chugging along
274             my $state = pop @{ $parser->{'_xspf'}->{'states'} };
275              
276             $state->{'cdata'} .= $value;
277              
278             push @{ $parser->{'_xspf'}->{'states'} }, $state;
279             }
280              
281             sub handleEndElement {
282             my ($parser, $element) = @_;
283              
284             my $state = pop @{ $parser->{'_xspf'}->{'states'} };
285             my $value = $state->{'cdata'};
286              
287             my $path = $parser->{'_xspf'}->{'path'};
288             my $self = $parser->{'_xspf'}->{'self'};
289              
290             # These are all single value elements.
291             if ($path eq '/playlist/annotation' ||
292             $path eq '/playlist/creator' ||
293             $path eq '/playlist/date' ||
294             $path eq '/playlist/identifier' ||
295             $path eq '/playlist/image' ||
296             $path eq '/playlist/info' ||
297             $path eq '/playlist/license' ||
298             $path eq '/playlist/location' ||
299             $path eq '/playlist/title') {
300              
301             # There should only be one value per track according to the spec.
302             if ($self->get($element)) {
303              
304             Carp::confess("Element: $path has too many values!\n");
305             }
306              
307             if (_validateLinkElement($path, 'playlist', $element, $value)) {
308              
309             $self->$element($value);
310             }
311             }
312              
313             if ($path eq '/playlist/attribution/identifier' ||
314             $path eq '/playlist/attribution/location') {
315              
316             if (_validateLinkElement($path, 'playlist', $element, $value)) {
317              
318             $self->append('attributions', [ $element, $value ]);
319             }
320             }
321              
322             if ($path eq '/playlist/meta' ||
323             $path eq '/playlist/link') {
324              
325             my $rel = $state->{'attributes'}->{'rel'};
326              
327             # Check both the value and the rel for validity.
328             if (_validateLinkElement($path, 'playlist', $element, $value, $rel)) {
329              
330             $self->append("${element}s", [ $rel, $value ]);
331             }
332             }
333              
334             # We've hit the end of a track definition - push it onto the end of the track list.
335             if ($path eq '/playlist/trackList/track') {
336              
337             push @{ $parser->{'_xspf'}->{'tracks'} }, $parser->{'_xspf'}->{'track'};
338             }
339              
340             # End of the trackList - set all the tracks we've acquired.
341             if ($path eq '/playlist/trackList') {
342              
343             $self->trackList($parser->{'_xspf'}->{'tracks'});
344             }
345              
346             # These can all have multiple values, but we render only one of them
347             # per the spec. Should we only store one?
348             if ($path eq '/playlist/trackList/track/location' ||
349             $path eq '/playlist/trackList/track/identifier') {
350              
351             if (_validateLinkElement($path, 'track', $element, $value)) {
352              
353             $parser->{'_xspf'}->{'track'}->append("${element}s", $value);
354             }
355             }
356              
357             if ($path eq '/playlist/trackList/track/meta' ||
358             $path eq '/playlist/trackList/track/link') {
359              
360             my $rel = $state->{'attributes'}->{'rel'};
361              
362             # Check both the value and the rel for validity.
363             if (_validateLinkElement($path, 'track', $element, $value, $rel)) {
364              
365             $parser->{'_xspf'}->{'track'}->append("${element}s", [ $rel, $value ]);
366             }
367             }
368              
369             # Single element track values.
370             if ($path eq '/playlist/trackList/track/album' ||
371             $path eq '/playlist/trackList/track/annotation' ||
372             $path eq '/playlist/trackList/track/creator' ||
373             $path eq '/playlist/trackList/track/duration' ||
374             $path eq '/playlist/trackList/track/image' ||
375             $path eq '/playlist/trackList/track/info' ||
376             $path eq '/playlist/trackList/track/title' ||
377             $path eq '/playlist/trackList/track/trackNum') {
378              
379             # There should only be one value per track according to the spec.
380             if ($parser->{'_xspf'}->{'track'}->get($element)) {
381              
382             Carp::confess("Element: $element has too many values!\n");
383             }
384              
385             # Check for invalid URIs
386             if (_validateLinkElement($path, 'track', $element, $value)) {
387              
388             $parser->{'_xspf'}->{'track'}->$element($value);
389             }
390             }
391              
392             if ($path eq '/playlist') {
393              
394             for my $attr (qw(version xmlns)) {
395              
396             if (defined $state->{'attributes'}->{$attr}) {
397              
398             $self->$attr($state->{'attributes'}->{$attr});
399              
400             } else {
401              
402             Carp::confess("Didn't find $attr in the element!\n");
403             }
404             }
405             }
406              
407             my @parts = split(/\//, $path);
408             pop @parts;
409             $parser->{'_xspf'}->{'path'} = join('/', @parts);
410             }
411              
412             sub version {
413             my $self = shift;
414              
415             if (defined $_[0] && $_[0] !~ /^[01]$/) {
416              
417             Carp::confess("XSPF Version is not 0 or 1!\n");
418             }
419              
420             return $self->_getSetWithDefaults('version', \%defaults, @_);
421             }
422              
423             sub xmlns {
424             my $self = shift;
425              
426             if (defined $_[0] && $_[0] ne 'http://xspf.org/ns/0/') {
427              
428             Carp::confess("xmlns MUST be http://xspf.org/ns/0/\n");
429             }
430              
431             return $self->_getSetWithDefaults('xmlns', \%defaults, @_);
432             }
433              
434             sub title {
435             shift->_getSetWithDefaults('title', \%defaults, @_);
436             }
437              
438             sub creator {
439             shift->_getSetWithDefaults('creator', \%defaults, @_);
440             }
441              
442             # Store the incoming time - either ISO 8601 or xsd:dateTime, and format it on
443             # the way out as xsd:dateTime for version 1.
444             sub date {
445             my $self = shift;
446              
447             if (@_) {
448              
449             my $date = str2time($_[0]);
450              
451             if ($date && $date =~ /^\d+$/) {
452              
453             $self->set('date', $date);
454              
455             } else {
456              
457             Carp::confess("Invalid date: [$_[0]]\n");
458             }
459              
460             } else {
461              
462             # Check the version to determine the date format.
463             # If the date isn't set - use the current date
464             my $date = $self->get('date') || time;
465              
466             if ($self->version == 0) {
467              
468             return strftime('%Y-%m-%d', localtime($date));
469              
470             } elsif ($self->version == 1) {
471              
472             my $xsd = strftime('%Y-%m-%dT%H:%M:%S', localtime($date));
473             my $tz = strftime('%z', localtime($date));
474             $tz =~ s/^([+-]\d{2})/$1:/;
475              
476             return $xsd . $tz;
477              
478             } else {
479              
480             Carp::confess("Couldn't figure out date format from version: [%d]\n", $self->version);
481             }
482             }
483             }
484              
485             sub trackList {
486             shift->_asArray('trackList', @_);
487             }
488              
489             sub metas {
490             shift->_asArray('metas', @_);
491             }
492              
493             sub links {
494             shift->_asArray('links', @_);
495             }
496              
497             sub attributions {
498             shift->_asArray('attributions', @_);
499             }
500              
501             sub _validateLinkElement {
502             my ($path, $parent, $element, $value, $rel) = @_;
503              
504             if ($uriElements{$parent}->{$element}) {
505              
506             if (!_isValidURI($value)) {
507             Carp::confess("Element: $path ($value) is not a valid URI!\n");
508             }
509              
510             if ($rel && !_isValidURI($rel)) {
511              
512             Carp::confess("Element: $path rel ($rel) value is not a valid URI!\n");
513             }
514             }
515              
516             return 1;
517             }
518              
519             sub _isValidURI {
520             return if $_[0] =~ /[^a-z0-9\:\/\?\#\[\]\@\!\$\&\'\(\)\*\+\,\;\=\.\-\_\~\%]/i;
521             return 1;
522             }
523              
524             1;
525              
526             __END__