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   113400 use strict;
  7         11  
  7         173  
3 7     7   23 use warnings;
  7         8  
  7         211  
4 7     7   21 use vars qw( $VERSION );
  7         9  
  7         313  
5 7     7   25 use base qw( MP3::M3U::Parser::Export );
  7         7  
  7         2870  
6 7     7   36 use Carp qw( croak );
  7         6  
  7         283  
7 7     7   29 use MP3::M3U::Parser::Constants;
  7         10  
  7         15885  
8              
9             $VERSION = '2.32';
10              
11             my %LOADED;
12              
13             sub new {
14             # -parse_path -seconds -search -overwrite
15 7     7 1 931 my($class, @args) = @_;
16 7 50       49 my %o = @args % 2 ? () : @args; # options
17             my $self = {
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 7   100     250 expdrives => $o{'-expdrives'} || EMPTY_STRING, # leave it to export() if no param
      100        
      100        
      100        
      100        
      100        
      50        
34             };
35 7         17 my $s = $self->{search_string};
36 7 50 66     44 if ( $s && length $s < MINIMUM_SEARCH_LENGTH ) {
37 0         0 croak 'A search string must be at least three characters long';
38             }
39 7         14 bless $self, $class;
40 7         29 return $self;
41             }
42              
43             sub parse {
44 7     7 1 1399 my($self, @files) = @_;
45              
46 7         19 foreach my $file ( @files ) {
47             $self->_parse_file(
48             ref $file ? $file
49 7 50       25 : do {
50 7         30 my $new = $self->_locate_file( $file );
51 7 50       117 croak "$new does not exist" if ! -e $new;
52 7         37 $new;
53             }
54             );
55             }
56              
57             # Average time of all the parsed songs:
58 7         14 my($ac, $tt) = ( $self->{ACOUNTER}, $self->{TOTAL_TIME} );
59 7 50 33     62 $self->{AVERAGE_TIME} = ($ac && $tt) ? $self->_seconds( $tt / $ac ) : 0;
60 7 100       36 return defined wantarray ? $self : undef;
61             }
62              
63             sub _check_parse_file_params {
64 7     7   12 my($self, $file) = @_;
65              
66 7         13 my $ref = ref $file;
67 7 0 33     22 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         12 my $cd;
72 7 50       15 if ( ! $ref ) {
73 7         37 my @tmp = split m{[\\/]}xms, $file;
74 7         35 ($cd = pop @tmp) =~ s{ [.] m3u }{}xmsi;
75             }
76              
77 7 50       25 my $this_file = $ref ? 'ANON'.$self->{ANON}++ : $self->_locate_file($file);
78              
79 7 50 50     123 $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         17 $self->{TOTAL_FILES} += 1; # Total lists counter
88              
89 7         10 my($fh, @fh);
90 7 50       55 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         2757 require IO::File;
99 7         33742 $fh = IO::File->new;
100 7 50       231 $fh->open( $file, '<' ) or croak "I could't open '$file': $!";
101             }
102 7         405 return $ref, $fh, @fh;
103             }
104              
105             sub _validate_m3u {
106 7     7   16 my($self, $next, $ref, $file) = @_;
107 7         19 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         14 chomp $m3u;
111 7 50       61 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         13 return;
116             }
117              
118             sub _iterator {
119 7     7   16 my($self, $ref, $fh, @fh) = @_;
120 7 50   120   55 return $ref eq 'SCALAR' ? sub { return shift @fh } : sub { return <$fh> };
  0         0  
  210         667  
121             }
122              
123             sub _extract_path {
124 98     98   138 my($self, $i, $m3u, $device_ref, $counter_ref) = @_;
125              
126 98 0 33     536 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         139 my $path = $1;
132 98 100       165 $i->[PATH] = $self->{parse_path} eq 'asis' ? $m3u : $path;
133 98 100 66     73 if ( ${$device_ref} eq DEFAULT_DRIVE && $m3u =~ m{ \A (\w:) }xms ) {
  98         215  
134 7         8 ${$device_ref} = $1;
  7         16  
135             }
136 98         77 ${ $counter_ref }++;
  98         83  
137             }
138 98         90 return;
139             }
140              
141             sub _extract_artist_song {
142 98     98   74 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     398 my($artist, @xsong) = split m{\s{1,}-\s{1,}}xms, $i->[ID3] || $i->[PATH];
150 98 50       155 if ( $artist ) {
151 98         132 $artist = $self->_trim( $artist );
152 98         163 $artist =~ s{.*[\\/]}{}xms; # remove path junk
153 98         102 $i->[ARTIST] = $artist;
154             }
155 98 50       140 if ( @xsong ) {
156 98         141 my $song = join q{-}, @xsong;
157 98         106 $song = $self->_trim( $song );
158 98         83 $song =~ s{ [.] [a-zA-Z0-9]+ \z }{}xms; # remove extension if exists
159 98         98 $i->[SONG] = $song;
160             }
161 98         102 return;
162             }
163              
164             sub _initialize {
165 98     98   64 my($self, $i);
166 98         131 foreach my $CHECK ( 0..MAXDATA ) {
167 588 50       900 $i->[$CHECK] = EMPTY_STRING if ! defined $i->[$CHECK];
168             }
169 98         115 return;
170             }
171              
172             sub _parse_file {
173             # supports disk files, scalar variables and filehandles (typeglobs)
174 7     7   17 my($self, $file) = @_;
175 7         39 my($ref, $fh, @fh) = $self->_check_parse_file_params( $file );
176 7         45 my $next = $self->_iterator( $ref, $fh, @fh );
177              
178 7         30 $self->_validate_m3u( $next, $ref, $file );
179              
180 7         25 my $dkey = $self->{_M3U_}[ $self->{INDEX} ]{data}; # data key
181 7         23 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         16 my($ttime,$tsong,$taver) = (0,0,0);
186 7         9 my $index = 0; # index number of the list array
187 7         8 my $temp_sec; # must be defined outside
188              
189 7         16 RECORD: while ( my $m3u = $next->() ) {
190 196         181 chomp $m3u;
191 196 50       258 next if ! $m3u; # Record may be blank if it is not a disk file.
192 196         130 $#{$dkey->[$index]} = MAXDATA; # For the absence of EXTINF line.
  196         380  
193             # If the extra information exists, parse it:
194 196 100       421 if ( $m3u =~ RE_INF_HEADER ) {
195 98         67 my($j, $sec, @song);
196 98         205 ($j ,@song) = split m{\,}xms, $m3u;
197 98         166 ($j ,$sec) = split m{:}xms, $j;
198 98         102 $temp_sec = $sec;
199 98         106 $ttime += $sec;
200 98         149 $dkey->[$index][ID3] = join q{,}, @song;
201 98   50     214 $dkey->[$index][LEN] = $self->_seconds($sec || 0);
202 98         83 $taver++;
203 98         168 next RECORD; # jump to path info
204             }
205              
206 98         83 my $i = $dkey->[$index];
207 98         155 $self->_extract_path( $i, $m3u, $device, \$tsong );
208 98         129 $self->_extract_artist_song( $i );
209 98         144 $self->_initialize( $i );
210              
211             # If we are searching something:
212 98 100       119 if ( $self->{search_string} ) {
213 14         26 my $matched = $self->_search( $i->[PATH], $i->[ID3] );
214 14 100       370 if ( $matched ) {
215 1         3 $index++; # if we got a match, increase the index
216             }
217             else {
218             # if we didnt match anything, resize these counters ...
219 13         7 $tsong--;
220 13         10 $taver--;
221 13         13 $ttime -= $temp_sec;
222 13         33 delete $dkey->[$index]; # ... and delete the empty index
223             }
224             }
225             else {
226 84         122 $index++; # If we are not searching, just increase the index
227             }
228             }
229              
230 7 50       72 $fh->close if ! $ref;
231 7         116 return $self->_set_parse_file_counters( $ttime, $tsong, $taver );
232             }
233              
234             sub _set_parse_file_counters {
235 7     7   13 my($self, $ttime, $tsong, $taver) = @_;
236              
237             # Calculate the total songs in the list:
238 7         20 my $k = $self->{_M3U_}[ $self->{INDEX} ];
239 7         7 $k->{total} = @{ $k->{data} };
  7         18  
240              
241             # Adjust the global counters:
242 7 50 66     31 $self->{TOTAL_FILES}-- if $self->{search_string} && $k->{total} == 0;
243 7         12 $self->{TOTAL_TIME} += $ttime;
244 7         10 $self->{TOTAL_SONGS} += $tsong;
245 7         10 $self->{ACOUNTER} += $taver;
246 7         12 $self->{INDEX}++;
247              
248 7         55 return $self;
249             }
250              
251             sub reset { ## no critic (ProhibitBuiltinHomonyms)
252             # reset the object
253 3     3 1 800 my $self = shift;
254 3         13 my @zeroes = qw(
255             TOTAL_FILES
256             TOTAL_TIME
257             TOTAL_SONGS
258             AVERAGE_TIME
259             ACOUNTER INDEX
260             );
261              
262 3         10 foreach my $field ( @zeroes ) {
263 18         22 $self->{ $field } = 0;
264             }
265              
266 3         7 $self->{_M3U_} = [];
267              
268 3 50       20 return defined wantarray ? $self : undef;
269             }
270              
271             sub result {
272 3     3 1 6 my $self = shift;
273 3 50       12 return(wantarray ? @{$self->{_M3U_}} : $self->{_M3U_});
  0         0  
274             }
275              
276             sub _locate_file {
277 22     22   103 require File::Spec;
278 22         27 my $self = shift;
279 22         21 my $file = shift;
280 22 100       79 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         30 require Cwd;
284 5         91 $file = File::Spec->catfile( Cwd::getcwd(), $file );
285             }
286 22         94 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   130 my($self, $module) = @_;
300 127 100       285 return 1 if $LOADED{ $module };
301 7         24 local $^W;
302 7         7 local $@;
303 7         44 local $!;
304 7         30 local $^E;
305 7         19 local $SIG{__DIE__};
306 7         19 local $SIG{__WARN__};
307 7         345 my $eok = eval qq{ require $module; 1; };
308 7 50 33     65 return 0 if $@ || !$eok;
309 7         19 $LOADED{ $module } = 1;
310 7         71 return 1;
311             }
312              
313             sub _escape {
314 127     127   554 my $self = shift;
315 127   50     186 my $text = shift || return EMPTY_STRING;
316 127 50       151 if ( $self->_is_loadable('HTML::Entities') ) {
317 127         205 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   163 my($self, $s) = @_;
332 212         232 $s =~ s{ \A \s+ }{}xmsg;
333 212         514 $s =~ s{ \s+ \z }{}xmsg;
334 212         259 return $s;
335             }
336              
337             sub info {
338             # Instead of direct accessing to object tables, use this method.
339 3     3 1 6 my $self = shift;
340 3         8 my $tt = $self->{TOTAL_TIME};
341             return
342             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     16 drive => [ map { $_->{drive} } @{ $self->{_M3U_} } ],
  3         24  
  3         11  
347             ;
348             }
349              
350             sub _seconds {
351             # Format seconds if wanted.
352 162     162   139 my $self = shift;
353 162         130 my $all = shift;
354 162 50       225 return '00:00' if ! $all;
355 162   100     618 my $ok = $self->{seconds} eq 'format' && $all !~ m{:}xms;
356 162 100       453 return $all if ! $ok;
357 100         106 $all = $all / MINUTE_MULTIPLIER;
358 100         134 my $min = int $all;
359 100         231 my $sec = sprintf '%02d', int( MINUTE_MULTIPLIER * ($all - $min) );
360 100         68 my $hr;
361 100 50       147 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         122 $min = sprintf q{%02d}, $min;
367 100 50       254 return $hr ? "$hr:$min:$sec" : "$min:$sec";
368             }
369              
370             1;
371              
372             __END__