File Coverage

blib/lib/MP3/M3U/Parser.pm
Criterion Covered Total %
statement 183 202 90.5
branch 45 82 54.8
condition 33 61 54.1
subroutine 24 25 96.0
pod 5 5 100.0
total 290 375 77.3


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