File Coverage

blib/lib/MP3/M3U/Parser.pm
Criterion Covered Total %
statement 186 205 90.7
branch 45 82 54.8
condition 33 61 54.1
subroutine 25 26 96.1
pod 5 5 100.0
total 294 379 77.5


line stmt bran cond sub pod time code
1             package MP3::M3U::Parser;
2 7     7   372660 use strict;
  7         20  
  7         445  
3 7     7   42 use warnings;
  7         14  
  7         261  
4 7     7   41 use vars qw( $VERSION );
  7         19  
  7         462  
5 7     7   39 use base qw( MP3::M3U::Parser::Export );
  7         13  
  7         6852  
6 7     7   2128 use Carp qw( croak );
  7         26  
  7         1382  
7 7     7   594 use MP3::M3U::Parser::Constants;
  7         17  
  7         45019  
8              
9             $VERSION = '2.31';
10              
11             my %LOADED;
12              
13             sub new {
14             # -parse_path -seconds -search -overwrite
15 7     7 1 1751 my($class, @args) = @_;
16 7 50       72 my %o = @args % 2 ? () : @args; # options
17 7   100     609 my $self = {
      100        
      100        
      100        
      100        
      100        
      50        
18             _M3U_ => [], # for parse()
19             TOTAL_FILES => 0, # Counter
20             TOTAL_TIME => 0, # In seconds
21             TOTAL_SONGS => 0, # Counter
22             AVERAGE_TIME => 0, # Counter
23             ACOUNTER => 0, # Counter
24             ANON => 0, # Counter for SCALAR & GLOB M3U
25             INDEX => 0, # index counter for _M3U_
26             EXPORTF => 0, # Export file name counter for anonymous exports
27             seconds => $o{'-seconds'} || EMPTY_STRING, # format or get seconds.
28             search_string => $o{'-search'} || EMPTY_STRING, # search_string
29             parse_path => $o{'-parse_path'} || EMPTY_STRING, # mixed list?
30             overwrite => $o{'-overwrite'} || 0, # overwrite export file if exists?
31             encoding => $o{'-encoding'} || EMPTY_STRING, # leave it to export() if no param
32             expformat => $o{'-expformat'} || EMPTY_STRING, # leave it to export() if no param
33             expdrives => $o{'-expdrives'} || EMPTY_STRING, # leave it to export() if no param
34             };
35 7         23 my $s = $self->{search_string};
36 7 50 66     59 if ( $s && length $s < MINIMUM_SEARCH_LENGTH ) {
37 0         0 croak 'A search string must be at least three characters long';
38             }
39 7         27 bless $self, $class;
40 7         36 return $self;
41             }
42              
43             sub parse {
44 7     7 1 2435 my($self, @files) = @_;
45              
46 7         26 foreach my $file ( @files ) {
47             $self->_parse_file(
48             ref $file ? $file
49 7 50       35 : do {
50 7         45 my $new = $self->_locate_file( $file );
51 7 50       170 croak "$new does not exist" if ! -e $new;
52 7         70 $new;
53             }
54             );
55             }
56              
57             # Average time of all the parsed songs:
58 7         33 my($ac, $tt) = ( $self->{ACOUNTER}, $self->{TOTAL_TIME} );
59 7 50 33     653 $self->{AVERAGE_TIME} = ($ac && $tt) ? $self->_seconds( $tt / $ac ) : 0;
60 7 100       56 return defined wantarray ? $self : undef;
61             }
62              
63             sub _check_parse_file_params {
64 7     7   20 my($self, $file) = @_;
65              
66 7         19 my $ref = ref $file;
67 7 0 33     37 if ( $ref && $ref ne 'GLOB' && $ref ne 'SCALAR' ) {
      33        
68 0         0 croak "Unknown parameter of type '$ref' passed to parse()";
69             }
70              
71 7         14 my $cd;
72 7 50       32 if ( ! $ref ) {
73 7         55 my @tmp = split m{[\\/]}xms, $file;
74 7         60 ($cd = pop @tmp) =~ s{ [.] m3u }{}xmsi;
75             }
76              
77 7 50       38 my $this_file = $ref ? 'ANON'.$self->{ANON}++ : $self->_locate_file($file);
78              
79 7 50 50     161 $self->{'_M3U_'}[ $self->{INDEX} ] = {
80             file => $this_file,
81             list => $ref ? $this_file : ($cd || EMPTY_STRING),
82             drive => DEFAULT_DRIVE,
83             data => [],
84             total => 0,
85             };
86              
87 7         23 $self->{TOTAL_FILES} += 1; # Total lists counter
88              
89 7         15 my($fh, @fh);
90 7 50       47 if ( $ref eq 'GLOB' ) {
    50          
91 0         0 $fh = $file;
92             }
93             elsif ( $ref eq 'SCALAR' ) {
94 0         0 @fh = split m{\n}xms, ${$file};
  0         0  
95             }
96             else {
97             # Open the file to parse:
98 7         9714 require IO::File;
99 7         84771 $fh = IO::File->new;
100 7 50       573 $fh->open( $file, '<' ) or croak "I could't open '$file': $!";
101             }
102 7         854 return $ref, $fh, @fh;
103             }
104              
105             sub _validate_m3u {
106 7     7   27 my($self, $next, $ref, $file) = @_;
107 7         35 PREPROCESS: while ( my $m3u = $next->() ) {
108             # First line is just a comment. But we need it to validate
109             # the file as a m3u playlist file.
110 7         24 chomp $m3u;
111 7 50       62 last PREPROCESS if $m3u =~ RE_M3U_HEADER;
112 0 0       0 croak $ref ? "The '$ref' parameter does not contain valid m3u data"
113             : "'$file' is not a valid m3u file";
114             }
115 7         23 return;
116             }
117              
118             sub _iterator {
119 7     7   27 my($self, $ref, $fh, @fh) = @_;
120 7 50   90   104 return $ref eq 'SCALAR' ? sub { return shift @fh } : sub { return <$fh> };
  0         0  
  210         1110  
121             }
122              
123             sub _extract_path {
124 98     98   204 my($self, $i, $m3u, $device_ref, $counter_ref) = @_;
125              
126 98 0 33     625 if ( $m3u =~ RE_DRIVE_PATH ||
      33        
127             $m3u =~ RE_NORMAL_PATH ||
128             $m3u =~ RE_PARTIAL_PATH
129             ) {
130             # Get the drive and path info.
131 98         400 my $path = $1;
132 98 100       435 $i->[PATH] = $self->{parse_path} eq 'asis' ? $m3u : $path;
133 98 100 66     108 if ( ${$device_ref} eq DEFAULT_DRIVE && $m3u =~ m{ \A (\w:) }xms ) {
  98         301  
134 7         13 ${$device_ref} = $1;
  7         24  
135             }
136 98         113 ${ $counter_ref }++;
  98         148  
137             }
138 98         158 return;
139             }
140              
141             sub _extract_artist_song {
142 98     98   130 my($self, $i) = @_;
143             # Try to extract artist and song info
144             # and remove leading and trailing spaces
145             # Some artist names can also have a "-" in it.
146             # For this reason; require that the data has " - " in it.
147             # ... but the spaces can be one or more.
148             # So, things like "artist-song" does not work...
149 98   33     625 my($artist, @xsong) = split m{\s{1,}-\s{1,}}xms, $i->[ID3] || $i->[PATH];
150 98 50       255 if ( $artist ) {
151 98         205 $artist = $self->_trim( $artist );
152 98         225 $artist =~ s{.*[\\/]}{}xms; # remove path junk
153 98         166 $i->[ARTIST] = $artist;
154             }
155 98 50       203 if ( @xsong ) {
156 98         220 my $song = join q{-}, @xsong;
157 98         197 $song = $self->_trim( $song );
158 98         281 $song =~ s{ [.] [a-zA-Z0-9]+ \z }{}xms; # remove extension if exists
159 98         186 $i->[SONG] = $song;
160             }
161 98         247 return;
162             }
163              
164             sub _initialize {
165 98     98   104 my($self, $i);
166 98         7691 foreach my $CHECK ( 0..MAXDATA ) {
167 588 50       7971 $i->[$CHECK] = EMPTY_STRING if ! defined $i->[$CHECK];
168             }
169 98         209 return;
170             }
171              
172             sub _parse_file {
173             # supports disk files, scalar variables and filehandles (typeglobs)
174 7     7   27 my($self, $file) = @_;
175 7         55 my($ref, $fh, @fh) = $self->_check_parse_file_params( $file );
176 7         61 my $next = $self->_iterator( $ref, $fh, @fh );
177              
178 7         50 $self->_validate_m3u( $next, $ref, $file );
179              
180 7         39 my $dkey = $self->{_M3U_}[ $self->{INDEX} ]{data}; # data key
181 7         28 my $device = \$self->{_M3U_}[ $self->{INDEX} ]{drive}; # device letter
182              
183             # These three variables are used when there is a '-search' parameter.
184             # long: total_time, total_songs, total_average_time
185 7         24 my($ttime,$tsong,$taver) = (0,0,0);
186 7         14 my $index = 0; # index number of the list array
187 7         14 my $temp_sec; # must be defined outside
188              
189 7         24 RECORD: while ( my $m3u = $next->() ) {
190 196         291 chomp $m3u;
191 196 50       435 next if ! $m3u; # Record may be blank if it is not a disk file.
192 196         198 $#{$dkey->[$index]} = MAXDATA; # For the absence of EXTINF line.
  196         605  
193             # If the extra information exists, parse it:
194 196 100       638 if ( $m3u =~ RE_INF_HEADER ) {
195 98         106 my($j, $sec, @song);
196 98         312 ($j ,@song) = split m{\,}xms, $m3u;
197 98         271 ($j ,$sec) = split m{:}xms, $j;
198 98         168 $temp_sec = $sec;
199 98         146 $ttime += $sec;
200 98         2418 $dkey->[$index][ID3] = join q{,}, @song;
201 98   50     361 $dkey->[$index][LEN] = $self->_seconds($sec || 0);
202 98         193 $taver++;
203 98         342 next RECORD; # jump to path info
204             }
205              
206 98         136 my $i = $dkey->[$index];
207 98         247 $self->_extract_path( $i, $m3u, $device, \$tsong );
208 98         338 $self->_extract_artist_song( $i );
209 98         558 $self->_initialize( $i );
210              
211             # If we are searching something:
212 98 100       434 if ( $self->{search_string} ) {
213 14         51 my $matched = $self->_search( $i->[PATH], $i->[ID3] );
214 14 100       834 if ( $matched ) {
215 1         4 $index++; # if we got a match, increase the index
216             }
217             else {
218             # if we didnt match anything, resize these counters ...
219 13         87 $tsong--;
220 13         15 $taver--;
221 13         21 $ttime -= $temp_sec;
222 13         65 delete $dkey->[$index]; # ... and delete the empty index
223             }
224             }
225             else {
226 84         297 $index++; # If we are not searching, just increase the index
227             }
228             }
229              
230 7 50       121 $fh->close if ! $ref;
231 7         182 return $self->_set_parse_file_counters( $ttime, $tsong, $taver );
232             }
233              
234             sub _set_parse_file_counters {
235 7     7   21 my($self, $ttime, $tsong, $taver) = @_;
236              
237             # Calculate the total songs in the list:
238 7         158 my $k = $self->{_M3U_}[ $self->{INDEX} ];
239 7         12 $k->{total} = @{ $k->{data} };
  7         23  
240              
241             # Adjust the global counters:
242 7 50 66     44 $self->{TOTAL_FILES}-- if $self->{search_string} && $k->{total} == 0;
243 7         19 $self->{TOTAL_TIME} += $ttime;
244 7         17 $self->{TOTAL_SONGS} += $tsong;
245 7         15 $self->{ACOUNTER} += $taver;
246 7         13 $self->{INDEX}++;
247              
248 7         432 return $self;
249             }
250              
251             sub reset { ## no critic (ProhibitBuiltinHomonyms)
252             # reset the object
253 3     3 1 1558 my $self = shift;
254 3         16 my @zeroes = qw(
255             TOTAL_FILES
256             TOTAL_TIME
257             TOTAL_SONGS
258             AVERAGE_TIME
259             ACOUNTER INDEX
260             );
261              
262 3         11 foreach my $field ( @zeroes ) {
263 18         37 $self->{ $field } = 0;
264             }
265              
266 3         9 $self->{_M3U_} = [];
267              
268 3 50       23 return defined wantarray ? $self : undef;
269             }
270              
271             sub result {
272 3     3 1 8 my $self = shift;
273 3 50       20 return(wantarray ? @{$self->{_M3U_}} : $self->{_M3U_});
  0         0  
274             }
275              
276             sub _locate_file {
277 22     22   183 require File::Spec;
278 22         44 my $self = shift;
279 22         41 my $file = shift;
280 22 100       118 if ($file !~ m{[\\/]}xms) {
281             # if $file does not have a slash in it then it is in the cwd.
282             # don't know if this code is valid in some other filesystems.
283 5         26 require Cwd;
284 5         147 $file = File::Spec->catfile( Cwd::getcwd(), $file );
285             }
286 22         176 return File::Spec->canonpath($file);
287             }
288              
289             sub _search {
290 0     0   0 my($self, $path, $id3) = @_;
291 0 0 0     0 return 0 if !$id3 && !$path;
292 0         0 my $search = quotemeta $self->{search_string};
293             # Try a basic case-insensitive match:
294 0 0 0     0 return 1 if $id3 =~ /$search/xmsi || $path =~ /$search/xmsi;
295 0         0 return 0;
296             }
297              
298             sub _is_loadable {
299 127     127   188 my($self, $module) = @_;
300 127 100       572 return 1 if $LOADED{ $module };
301 7         39 local $^W;
302 7         17 local $@;
303 7         54 local $!;
304 7         31 local $^E;
305 7         36 local $SIG{__DIE__};
306 7         27 local $SIG{__WARN__};
307 7         767 my $eok = eval qq{ require $module; 1; };
308 7 50 33     110 return 0 if $@ || !$eok;
309 7         32 $LOADED{ $module } = 1;
310 7         182 return 1;
311             }
312              
313             sub _escape {
314 127     127   2320 my $self = shift;
315 127   50     310 my $text = shift || return EMPTY_STRING;
316 127 50       287 if ( $self->_is_loadable('HTML::Entities') ) {
317 127         3267 return HTML::Entities::encode_entities_numeric( $text );
318             }
319             # fall-back to lame encoder
320 0         0 my %escape = qw(
321             & &
322             " "
323             < <
324             > >
325             );
326 0         0 $text =~ s/ \Q$_\E /$escape{$_}/xmsg foreach keys %escape;
327 0         0 return $text;
328             }
329              
330             sub _trim {
331 212     212   357 my($self, $s) = @_;
332 212         610 $s =~ s{ \A \s+ }{}xmsg;
333 212         1252 $s =~ s{ \s+ \z }{}xmsg;
334 212         995 return $s;
335             }
336              
337             sub info {
338             # Instead of direct accessing to object tables, use this method.
339 3     3 1 9 my $self = shift;
340 3         14 my $tt = $self->{TOTAL_TIME};
341             return
342 3         43 songs => $self->{TOTAL_SONGS},
343             files => $self->{TOTAL_FILES},
344             ttime => $tt ? $self->_seconds( $tt ) : 0,
345             average => $self->{AVERAGE_TIME} || 0,
346 3 50 50     26 drive => [ map { $_->{drive} } @{ $self->{_M3U_} } ],
  3         13  
347             ;
348             }
349              
350             sub _seconds {
351             # Format seconds if wanted.
352 162     162   280 my $self = shift;
353 162         219 my $all = shift;
354 162 50       333 return '00:00' if ! $all;
355 162   100     992 my $ok = $self->{seconds} eq 'format' && $all !~ m{:}xms;
356 162 100       4172 return $all if ! $ok;
357 100         154 $all = $all / MINUTE_MULTIPLIER;
358 100         144 my $min = int $all;
359 100         317 my $sec = sprintf '%02d', int( MINUTE_MULTIPLIER * ($all - $min) );
360 100         104 my $hr;
361 100 50       219 if ( $min > MINUTE_MULTIPLIER ) {
362 0         0 $all = $min / MINUTE_MULTIPLIER;
363 0         0 $hr = int $all;
364 0         0 $min = int( MINUTE_MULTIPLIER * ($all - $hr) );
365             }
366 100         170 $min = sprintf q{%02d}, $min;
367 100 50       570 return $hr ? "$hr:$min:$sec" : "$min:$sec";
368             }
369              
370             1;
371              
372             __END__