diff --git a/tools/mp3info.pm b/tools/mp3info.pm index d2f6ce9708..10c5c20920 100644 --- a/tools/mp3info.pm +++ b/tools/mp3info.pm @@ -1,10 +1,19 @@ package mp3info; +# JJ: fixed bugs and stuff and added META info +# JRF: Added support for ID3v2.4 spec-valid frame size processing (falling back to old +# non-spec valid frame size processing) +# Added support for ID3v2.4 footers. +# Updated text frames to correct mis-terminated frame content. +# Added ignoring of encrypted frames. +# TODO: sort out flags for compression / DLI + require 5.006; -use overload; use strict; +use overload; use Carp; +use Fcntl qw(:seek); use vars qw( @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION $REVISION @@ -12,12 +21,13 @@ use vars qw( @t_bitrate @t_sampling_freq @frequency_tbl %v1_tag_fields @v1_tag_names %v2_tag_names %v2_to_v1_names $AUTOLOAD @mp3_info_fields %rva2_channel_types + $debug_24 $debug_Tencoding ); @ISA = 'Exporter'; @EXPORT = qw( set_mp3tag get_mp3tag get_mp3info remove_mp3tag - use_winamp_genres, use_mp3_utf8 + use_winamp_genres ); @EXPORT_OK = qw(@mp3_genres %mp3_genres use_mp3_utf8); %EXPORT_TAGS = ( @@ -26,8 +36,13 @@ use vars qw( all => [@EXPORT, @EXPORT_OK] ); +# $Id$ ($REVISION) = ' $Revision$ ' =~ /\$Revision:\s+([^\s]+)/; -$VERSION = '1.20'; +$VERSION = '1.26'; # Just adds metadata + +# JRF: Whether we're debugging the ID3v2.4 support +$debug_24 = 0; +$debug_Tencoding = 0; =pod @@ -166,19 +181,28 @@ with the C<:utf8> or C<:all> export tag. =cut -my $unicode_module = eval { require Encode; require Encode::Guess }; -my $UNICODE = use_mp3_utf8($unicode_module ? 1 : 0); +my $unicode_base_module = eval { require Encode; require Encode::Guess }; + +my $UNICODE = use_mp3_utf8($unicode_base_module ? 1 : 0); + +eval { require Encode::Detect::Detector }; + +my $unicode_detect_module = $@ ? 0 : 1; sub use_mp3_utf8 { - my($val) = @_; + my $val = shift; + + $UNICODE = 0; + if ($val == 1) { - if ($unicode_module) { - $UNICODE = 1; + + if ($unicode_base_module) { + $Encode::Guess::NoUTFAutoGuess = 1; + $UNICODE = 1; } - } elsif ($val == 0) { - $UNICODE = 0; } + return $UNICODE; } @@ -254,7 +278,7 @@ sub remove_mp3tag { binmode $fh; if ($version eq 1 || $version eq 'ALL') { - seek $fh, -128, 2; + seek $fh, -128, SEEK_END; my $tell = tell $fh; if (<$fh> =~ /^TAG/) { truncate $fh, $tell or carp "Can't truncate '$file': $!"; @@ -266,14 +290,14 @@ sub remove_mp3tag { my $v2h = _get_v2head($fh); if ($v2h) { local $\; - seek $fh, 0, 2; + seek $fh, 0, SEEK_END; my $eof = tell $fh; my $off = $v2h->{tag_size}; while ($off < $eof) { - seek $fh, $off, 0; + seek $fh, $off, SEEK_SET; read $fh, my($bytes), $buf; - seek $fh, $off - $v2h->{tag_size}, 0; + seek $fh, $off - $v2h->{tag_size}, SEEK_SET; print $fh $bytes; $off += $buf; } @@ -282,6 +306,9 @@ sub remove_mp3tag { or carp "Can't truncate '$file': $!"; $return += $v2h->{tag_size}; } + + # JRF: I've not written the code to strip ID3v2.4 footers. + # Sorry, I'm lazy. } _close($file, $fh); @@ -399,9 +426,9 @@ EOT binmode $fh; $oldfh = select $fh; - seek $fh, -128, 2; - # go to end of file if no tag, beginning of file if tag - seek $fh, (<$fh> =~ /^TAG/ ? -128 : 0), 2; + seek $fh, -128, SEEK_END; + # go to end of file if no ID3v1 tag, beginning of existing tag if tag present + seek $fh, (<$fh> =~ /^TAG/ ? -128 : 0), SEEK_END; # get genre value $info{GENRE} = $info{GENRE} && exists $mp3_genres{$info{GENRE}} ? @@ -424,7 +451,7 @@ EOT =pod -=item get_mp3tag (FILE [, VERSION, RAW_V2]) +=item get_mp3tag (FILE [, VERSION, RAW_V2, APE2]) Returns hash reference containing tag information in MP3 file. The keys returned are the same as those supplied for C, except in the @@ -444,6 +471,8 @@ If RAW_V2 is C<2>, the ID3v2 tag data is returned, manipulating for Unicode if necessary, etc. It also takes multiple values for a given key (such as comments) and puts them in an arrayref. +If APE is C<1>, an APE tag will be located before all other tags. + If the ID3v2 version is older than ID3v2.2.0 or newer than ID3v2.4.0, it will not be read. @@ -456,19 +485,21 @@ data (if TAGVERSION argument is C<0>, may contain two versions). =cut sub get_mp3tag { - my ($file, $ver, $raw_v2, $find_ape) = @_; - my ($tag, $v2h, $fh); + my $file = shift; + my $ver = shift || 0; + my $raw = shift || 0; + my $find_ape = shift || 0; + my $fh; - my $v1 = {}; - my $v2 = {}; - my $ape = {}; - my %info = (); - my @array = (); + my $has_v1 = 0; + my $has_v2 = 0; + my $has_ape = 0; + my %info = (); - $raw_v2 ||= 0; + # See if a version number was passed. Make sure it's a 1 or a 2 $ver = !$ver ? 0 : ($ver == 2 || $ver == 1) ? $ver : 0; - if (not (defined $file && $file ne '')) { + if (!(defined $file && $file ne '')) { $@ = "No file specified"; return undef; } @@ -480,13 +511,17 @@ sub get_mp3tag { return undef; } - if (ref $file) { # filehandle passed + # filehandle passed + if (ref $file) { + $fh = $file; + } else { - if (not open $fh, '<', $file) { + + open($fh, $file) || do { $@ = "Can't open $file: $!"; return undef; - } + }; } binmode $fh; @@ -495,14 +530,14 @@ sub get_mp3tag { # store ReplayGain information if ($find_ape) { - $ape = _parse_ape_tag($fh, $filesize, \%info); + $has_ape = _parse_ape_tag($fh, $filesize, \%info); } if ($ver < 2) { - $v1 = _get_v1tag($fh, \%info); + $has_v1 = _get_v1tag($fh, \%info); - if ($ver == 1 && !$v1) { + if ($ver == 1 && !$has_v1) { _close($file, $fh); $@ = "No ID3v1 tag found"; return undef; @@ -510,37 +545,20 @@ sub get_mp3tag { } if ($ver == 2 || $ver == 0) { - ($v2, $v2h) = _get_v2tag($fh); + $has_v2 = _get_v2tag($fh, $ver, $raw, \%info); } - if (!$v1 && !$v2 && !$ape) { + if (!$has_v1 && !$has_v2 && !$has_ape) { _close($file, $fh); - $@ = "No ID3 tag found"; + $@ = "No ID3 or APE tag found"; return undef; } - if (($ver == 0 || $ver == 2) && $v2) { + unless ($raw && $ver == 2) { - if ($raw_v2 == 1 && $ver == 2) { - - %info = %$v2; - - $info{'TAGVERSION'} = $v2h->{'version'}; - - } else { - - _parse_v2tag($raw_v2, $v2, \%info); - - if ($ver == 0 && $info{'TAGVERSION'}) { - $info{'TAGVERSION'} .= ' / ' . $v2h->{'version'}; - } else { - $info{'TAGVERSION'} = $v2h->{'version'}; - } - } - } - - unless ($raw_v2 && $ver == 2) { + # Strip out NULLs unless we want the raw data. foreach my $key (keys %info) { + if (defined $info{$key}) { $info{$key} =~ s/\000+.*//g; $info{$key} =~ s/\s+$//; @@ -552,19 +570,19 @@ sub get_mp3tag { } } - if (keys %info && exists $info{'GENRE'} && ! defined $info{'GENRE'}) { + if (keys %info && !defined $info{'GENRE'}) { $info{'GENRE'} = ''; } _close($file, $fh); - return keys %info ? {%info} : undef; + return keys %info ? \%info : undef; } sub _get_v1tag { my ($fh, $info) = @_; - seek $fh, -128, 2; + seek $fh, -128, SEEK_END; read($fh, my $tag, 128); if (!defined($tag) || $tag !~ /^TAG/) { @@ -590,51 +608,69 @@ sub _get_v1tag { $info->{'TAGVERSION'} = 'ID3v1'; } - if ($UNICODE) { + if (!$UNICODE) { + return 1; + } - # Save off the old suspects list, since we add - # iso-8859-1 below, but don't want that there - # for possible ID3 v2.x parsing below. - my $oldSuspects = $Encode::Encoding{'Guess'}->{'Suspects'}; + # Save off the old suspects list, since we add + # iso-8859-1 below, but don't want that there + # for possible ID3 v2.x parsing below. + my $oldSuspects = $Encode::Encoding{'Guess'}->{'Suspects'}; - for my $key (keys %{$info}) { + for my $key (keys %{$info}) { - next unless $info->{$key}; + next unless $info->{$key}; - # Try and guess the encoding. - my $value = $info->{$key}; - my $icode = Encode::Guess->guess($value); + # Try and guess the encoding. + if ($unicode_detect_module) { - unless (ref($icode)) { + my $charset = Encode::Detect::Detector::detect($info->{$key}) || 'iso-8859-1'; + my $enc = Encode::find_encoding($charset); - # Often Latin1 bytes are - # stuffed into a 1.1 tag. - Encode::Guess->add_suspects('iso-8859-1'); + if ($enc) { - while (length($value)) { + $info->{$key} = $enc->decode($info->{$key}, 0); - $icode = Encode::Guess->guess($value); - - last if ref($icode); - - # Remove garbage and retry - # (string is truncated in the - # middle of a multibyte char?) - $value =~ s/(.)$//; - } + next; } - - $info->{$key} = Encode::decode(ref($icode) ? $icode->name : 'iso-8859-1', $info->{$key}); } - Encode::Guess->set_suspects(keys %{$oldSuspects}); + my $value = $info->{$key}; + my $icode = Encode::Guess->guess($value); + + if (!ref($icode)) { + + # Often Latin1 bytes are + # stuffed into a 1.1 tag. + Encode::Guess->add_suspects('iso-8859-1'); + + while (length($value)) { + + $icode = Encode::Guess->guess($value); + + last if ref($icode); + + # Remove garbage and retry + # (string is truncated in the + # middle of a multibyte char?) + # Changed thanks to https://rt.cpan.org/Ticket/Display.html?id=101620 + substr($value, -1, 1, ''); + } + } + + $info->{$key} = Encode::decode(ref($icode) ? $icode->name : 'iso-8859-1', $info->{$key}); + + # Trim any trailing nuls + $info->{$key} =~ s/\x00+$//g; } + Encode::Guess->set_suspects(keys %{$oldSuspects}); + return 1; } sub _parse_v2tag { - my ($raw_v2, $v2, $info) = @_; + my ($ver, $raw_v2, $v2, $info) = @_; # Make sure any existing TXXX flags are an array. # As we might need to append comments to it below. @@ -661,7 +697,7 @@ sub _parse_v2tag { my $hash = $raw_v2 == 2 ? { map { ($_, $_) } keys %v2_tag_names } : \%v2_to_v1_names; - for my $id (keys %$hash) { + for my $id (keys %{$hash}) { next if !exists $v2->{$id}; @@ -724,6 +760,11 @@ sub _parse_v2tag { my $pic = $v2->{$id}; # if there is more than one picture, just grab the first one. + # JRF: Should consider looking for either the thumbnail or the front cover, + # rather than just returning the first one. + # Possibly also checking that the format is actually understood, + # but that's really down to the caller - we can't say whether the + # format is understood here. if (ref($pic) eq 'ARRAY') { $pic = (@$pic)[0]; } @@ -751,7 +792,7 @@ sub _parse_v2tag { } elsif ($pic && $id eq 'APIC') { - # look for ID3 v2.3 picture + # look for ID3 v2.3/2.4 picture my ($encoding, $format) = unpack 'C Z*', $pic; $pic_len = length($format) + 2; @@ -762,8 +803,8 @@ sub _parse_v2tag { $pic_len += 1 + length($description) + 1; - # skip extra terminating null if unicode - if ($encoding) { $pic_len++; } + # skip extra terminating null if UTF-16 (encoding 1 or 2) + if ( $encoding == 1 || $encoding == 2 ) { $pic_len++; } $valid_pic = 1; $pic_format = $format; @@ -787,24 +828,7 @@ sub _parse_v2tag { } else { my $data1 = $v2->{$id}; - # this is tricky ... if this is an arrayref, - # we want to only return one, so we pick the - # first one. but if it is a comment, we pick - # the first one where the first charcter after - # the language is NULL and not an additional - # sub-comment, because that is most likely to be - # the user-supplied comment - if (ref $data1 && !$raw_v2) { - if ($id =~ /^COMM?$/) { - my($newdata) = grep /^(....\000)/, @{$data1}; - $data1 = $newdata || $data1->[0]; - } elsif ($id !~ /^(?:TXXX?|PRIV)$/) { - # We can get multiple User Defined Text frames in a mp3 file - $data1 = $data1->[0]; - } - } - - $data1 = [ $data1 ] if ! ref $data1; + $data1 = [ $data1 ] if ref($data1) ne 'ARRAY'; for my $data (@$data1) { # TODO : this should only be done for certain frames; @@ -816,11 +840,13 @@ sub _parse_v2tag { my $desc; # Comments & Unsyncronized Lyrics have the same format. - if ($id =~ /^(COM[M ]?|USLT)$/) { # space for iTunes brokenness + if ($id =~ /^(COM[M ]?|US?LT)$/) { # space for iTunes brokenness $data =~ s/^(?:...)//; # strip language } + # JRF: I believe this should probably only be applied to the text frames + # and not every single frame. if ($UNICODE) { if ($encoding eq "\001" || $encoding eq "\002") { # UTF-16, UTF-16BE @@ -844,14 +870,26 @@ sub _parse_v2tag { # Only guess if it's not ascii. if ($data && $data !~ /^[\x00-\x7F]+$/) { - # Try and guess the encoding, otherwise just use latin1 - my $dec = Encode::Guess->guess($data); + if ($unicode_detect_module) { + + my $charset = Encode::Detect::Detector::detect($data) || 'iso-8859-1'; + my $enc = Encode::find_encoding($charset); + + if ($enc) { + $data = $enc->decode($data, 0); + } - if (ref $dec) { - $data = $dec->decode($data); } else { - # Best try - $data = Encode::decode('iso-8859-1', $data); + + # Try and guess the encoding, otherwise just use latin1 + my $dec = Encode::Guess->guess($data); + + if (ref $dec) { + $data = $dec->decode($data); + } else { + # Best try + $data = Encode::decode('iso-8859-1', $data); + } } } } @@ -863,12 +901,18 @@ sub _parse_v2tag { # convert to ASCII per best-effort my $pat; if ($data =~ s/^\xFF\xFE//) { + # strip additional BOMs as seen in COM(M?) and TXX(X?) + $data = join ("",map { ( /^(..)$/ && ! /(\xFF\xFE)/ )? $_: "" } (split /(..)/, $data)); $pat = 'v'; } elsif ($data =~ s/^\xFE\xFF//) { + # strip additional BOMs as seen in COM(M?) and TXX(X?) + $data = join ("",map { ( /^(..)$/ && ! /(\xFF\xFE)/ )? $_: "" } (split /(..)/, $data)); $pat = 'n'; } if ($pat) { + # strip additional 0s + $data = join ("",map { ( /^(..)$/ && ! /(\x00\x00)/ )? $_: "" } (split /(..)/, $data)); $data = pack 'C*', map { (chr =~ /[[:ascii:]]/ && chr =~ /[[:print:]]/) ? $_ @@ -879,13 +923,18 @@ sub _parse_v2tag { # We do this after decoding so we could be certain we're dealing # with 8-bit text. - if ($id =~ /^(COM[M ]?|USLT)$/) { # space for iTunes brokenness + if ($id =~ /^(COM[M ]?|US?LT)$/) { # space for iTunes brokenness $data =~ s/^(.*?)\000//; # strip up to first NULL(s), # for sub-comments (TODO: # handle all comment data) $desc = $1; + if ($encoding eq "\001" || $encoding eq "\002") { + + $data =~ s/^\x{feff}//; + } + } elsif ($id =~ /^TCON?$/) { my ($index, $name); @@ -912,12 +961,27 @@ sub _parse_v2tag { my @genres = (); - while ($data =~ s/^ \( (\d+) \)\000?//x) { + while ($data =~ s/^ \( (\d+) \)//x) { - push @genres, $mp3_genres[$1]; + # The indexes might have a refinement + # not sure why one wouldn't just use + # the proper genre in the first place.. + if ($data =~ s/^ ( [^\(]\D+ ) ( \000 | \( | \Z)/$2/x) { + + push @genres, $1; + + } else { + + push @genres, $mp3_genres[$1]; + } } $data = \@genres; + + } elsif ($data =~ /^[^\000]+\000/) { + + # name genres separated by nulls. + $data = [ split /\000/, $data ]; } # Text based genres will fall through. @@ -926,10 +990,52 @@ sub _parse_v2tag { } elsif (defined $index) { $data = $mp3_genres[$index]; } + + # Collapse single genres down, as we may have another tag. + if ($data && ref($data) eq 'ARRAY' && scalar @$data == 1) { + + $data = $data->[0]; + } + + } elsif ($id =~ /^T...?$/ && $id ne 'TXXX') { + + # In ID3v2.4 there's a slight content change for text fields. + # They can contain multiple values which are nul terminated + # within the frame. We ONLY want to split these into multiple + # array values if they didn't request raw values (1). + # raw_v2 = 0 => parse simply + # raw_v2 = 1 => don't parse + # raw_v2 = 2 => do split into arrayrefs + + # Strip off any trailing NULs, which would indicate an empty + # field and cause an array with no elements to be created. + $data =~ s/\x00+$//; + + + if ($data =~ /\x00/ && ($raw_v2 == 2 || $raw_v2 == 0)) + { + # There are embedded nuls in the string, which means an ID3v2.4 + # multi-value frame. And they wanted arrays rather than simple + # values. + # Strings are already UTF-8, so any double nuls from 16 bit + # characters will have already been reduced to single nuls. + $data = [ split /\000/, $data ]; + } } - if ($raw_v2 == 2 && $desc) { - $data = { $desc => $data }; + if ($desc) + { + # It's a frame with a description, so we may need to construct a hash + # for the data, rather than an array. + if ($raw_v2 == 2) { + + $data = { $desc => $data }; + + } elsif ($desc =~ /^iTun/) { + + # leave iTunes tags alone. + $data = join(' ', $desc, $data); + } } if ($raw_v2 == 2 && exists $info->{$hash->{$id}}) { @@ -946,6 +1052,13 @@ sub _parse_v2tag { if ($id eq 'TXXX') { my ($key, $val) = split(/\0/, $data); + + # Some programs - such as FB2K leave a UTF-16 BOM on the value + if ($encoding eq "\001" || $encoding eq "\002") { + + $val =~ s/^\x{feff}//; + } + $info->{uc($key)} = $val; } elsif ($id eq 'PRIV') { @@ -955,7 +1068,38 @@ sub _parse_v2tag { } else { - $info->{$hash->{$id}} = $data; + my $key = $hash->{$id}; + + # If we have multiple values + # for the same key - turn them + # into an array ref. + if ($ver == 2 && $info->{$key} && !ref($info->{$key})) { + + if (ref($data) eq "ARRAY") { + + $info->{$key} = [ $info->{$key}, @$data ]; + } else { + + my $old = delete $info->{$key}; + + @{$info->{$key}} = ($old, $data); + } + + } elsif ($ver == 2 && ref($info->{$key}) eq 'ARRAY') { + + if (ref($data) eq "ARRAY") { + + push @{$info->{$key}}, @$data; + + } else { + + push @{$info->{$key}}, $data; + } + + } else { + + $info->{$key} = $data; + } } } } @@ -964,26 +1108,113 @@ sub _parse_v2tag { } sub _get_v2tag { - my($fh) = @_; + my ($fh, $ver, $raw, $info, $start) = @_; + my $eof; + my $gotanyv2 = 0; + + # First we need to check the end of the file for any footer + + seek $fh, -128, SEEK_END; + $eof = (tell $fh) + 128; + + # go to end of file if no ID3v1 tag, beginning of existing tag if tag present + if (<$fh> =~ /^TAG/) { + $eof -= 128; + } + + seek $fh, $eof, SEEK_SET; + # print STDERR "Checking for footer at $eof\n"; + + if (my $v2f = _get_v2foot($fh)) { + $eof -= $v2f->{tag_size}; + # We have a ID3v2.4 footer. Must read it. + $gotanyv2 |= (_get_v2tagdata($fh, $ver, $raw, $info, $eof) ? 2 : 0); + } + + # Now read any ID3v2 header + $gotanyv2 |= (_get_v2tagdata($fh, $ver, $raw, $info, $start) ? 1 : 0); + + # Because we've merged the entries it makes sense to trim any duplicated + # values - for example if there's a footer and a header that contain the same + # data then this results in every entry being an array containing two + # identical values. + for my $name (keys %{$info}) + { + # Note: We must not sort these elements to do the comparison because that + # changes the order in which they are claimed to appear. Whilst this + # probably isn't important, it may matter for default display - for + # example a lyric should be shown by default with the first entry + # in the tag in the case where the user has not specified a language + # preference. If we sorted the array it would destroy that order. + # This is a longwinded way of checking for duplicates and only writing the + # first element - we check the array for duplicates and clear all subsequent + # entries which are duplicates of earlier ones. + if (ref $info->{$name} eq 'ARRAY') + { + my @array = (); + my ($i, $o); + my @chk = @{$info->{$name}}; + for $i ( 0..$#chk ) + { + my $ielement = $chk[$i]; + if (defined $ielement) + { + for $o ( ($i+1)..$#chk ) + { + $chk[$o] = undef if (defined $o && defined $chk[$o] && ($ielement eq $chk[$o])); + } + push @array, $ielement; + } + } + # We may have reduced the array to a single element. If so, just assign + # a regular scalar instead of the array. + if ($#array == 0) + { + $info->{$name} = $array[0]; + } + else + { + $info->{$name} = \@array; + } + } + } + + return $gotanyv2; +} + +# $has_v2 = &_get_v2tagdata($filehandle, $ver, $raw, $info, $startinfile); +# $info is a hash reference which will be updated with the new ID3v2 details +# if the updated bit is set, and set to the new details if the updated bit +# is clear. +# If undefined, $startinfile will be treated as 0 (see _get_v2head). +# $v2h is a reference to a hash of the frames present within the tag. +# Any frames which are repeated within the tag (eg USLT with different +# languages) will be supplied as an array rather than a scalar. All client +# code needs to be aware that any frame may be duplicated. +sub _get_v2tagdata { + my($fh, $ver, $raw, $info, $start) = @_; my($off, $end, $myseek, $v2, $v2h, $hlen, $num, $wholetag); $v2 = {}; - $v2h = _get_v2head($fh) or return; + $v2h = _get_v2head($fh, $start) or return 0; if ($v2h->{major_version} < 2) { carp "This is $v2h->{version}; " . "ID3v2 versions older than ID3v2.2.0 not supported\n" if $^W; - return; + return 0; } # use syncsafe bytes if using version 2.4 - # my $bytesize = ($v2h->{major_version} > 3) ? 128 : 256; + my $id3v2_4_frame_size_broken = 0; + my $bytesize = ($v2h->{major_version} > 3) ? 128 : 256; # alas, that's what the spec says, but iTunes and others don't syncsafe # the length, which breaks MP3 files with v2.4 tags longer than 128 bytes, # like every image file. - my $bytesize = 256; + # Because we should not break the spec conformant files due to + # spec-inconformant programs, we first try the correct form and if the + # data looks wrong we revert to broken behaviour. if ($v2h->{major_version} == 2) { $hlen = 6; @@ -996,70 +1227,308 @@ sub _get_v2tag { $off = $v2h->{ext_header_size} + 10; $end = $v2h->{tag_size} + 10; # should we read in the footer too? - seek $fh, $v2h->{offset}, 0; + # JRF: If the format was ID3v2.2 and the compression bit was set, then we can't + # actually read the content because there are no defined compression schemes + # for ID3v2.2. Perform no more processing, and return failure because we + # cannot read anything. + return 0 if ($v2h->{major_version} == 2 && $v2h->{compression}); + + # JRF: If the update flag is set then the input data is the same as that which was + # passed in. ID3v2.4 section 3.2. + if ($v2h->{update}) { + $v2 = $info; + } + + # Bug 8939, Trying to read past the end of the file may crash on win32 + my $size = -s $fh; + if ( $v2h->{offset} + $end > $size ) { + $end -= $v2h->{offset} + $end - $size; + } + + seek $fh, $v2h->{offset}, SEEK_SET; read $fh, $wholetag, $end; + # JRF: The discrepency between ID3v2.3 and ID3v2.4 is that : + # 2.3: unsync flag indicates that unsync is used on the entire tag + # 2.4: unsync flag indicates that all frames have the unsync bit set + # In 2.4 this means that the size of the frames which have the unsync bit + # set will be the unsync'd size (section 4. in the ID3v2.4.0 structure + # specification). + # This means that when processing 2.4 files we should perform all the + # unsynchronisation processing at the frame level, not the tag level. + # The tag unsync bit is redundant (IMO). + if ($v2h->{major_version} == 4) { + $v2h->{unsync} = 0 + } + $wholetag =~ s/\xFF\x00/\xFF/gs if $v2h->{unsync}; + # JRF: If we /knew/ there would be something special in the tag which meant + # that the ID3v2.4 frame size was broken we could check it here. If, + # for example, the iTunes files had the word 'iTunes' somewhere in the + # tag and we knew that it was broken for versions below 3.145 (which is + # a number I just picked out of the air), then we could do something like this : + # if ($v2h->{major_version} == 4) && + # $wholetag =~ /iTunes ([0-9]+\.[0-9]+)/ && + # $1 < 3.145) + # { + # $id3v2_4_frame_size_broken = 1; + # } + # However I have not included this because I don't have examples of broken + # files - and in any case couldn't guarentee I'd get it right. + $myseek = sub { + return unless $wholetag; + my $bytes = substr($wholetag, $off, $hlen); - return unless $bytes =~ /^([A-Z0-9]{$num})/ - || ($num == 4 && $bytes =~ /^(COM )/); # stupid iTunes - my($id, $size) = ($1, $hlen); + + # iTunes is stupid and sticks ID3v2.2 3 byte frames in a + # ID3v2.3 or 2.4 header. Ignore tags with a space in them. + if ($bytes !~ /^([A-Z0-9\? ]{$num})/) { + return; + } + + my ($id, $size) = ($1, $hlen); my @bytes = reverse unpack "C$num", substr($bytes, $num, $num); for my $i (0 .. ($num - 1)) { $size += $bytes[$i] * $bytesize ** $i; } + # JRF: Now provide the fall back for the broken ID3v2.4 frame size + # (which will persist for subsequent frames if detected). + + # Part 1: If the frame size cannot be valid according to the + # specification (or if it would be larger than the tag + # size allows). + if ($v2h->{major_version}==4 && + $id3v2_4_frame_size_broken == 0 && # we haven't detected brokenness yet + ((($bytes[0] | $bytes[1] | $bytes[2] | $bytes[3]) & 0x80) != 0 || # 0-bits set in size + $off + $size > $end) # frame size would excede the tag end + ) + { + # The frame is definately not correct for the specification, so drop to + # broken frame size system instead. + $bytesize = 128; + $size -= $hlen; # hlen has alread been added, so take that off again + $size = (($size & 0x0000007f)) | + (($size & 0x00003f80)<<1) | + (($size & 0x001fc000)<<2) | + (($size & 0x0fe00000)<<3); # convert spec to non-spec sizes + + $size += $hlen; # and re-add header len so that the entire frame's size is known + + $id3v2_4_frame_size_broken = 1; + + print "Frame size cannot be valid ID3v2.4 (part 1); reverting to broken behaviour\n" if ($debug_24); + + } + + # Part 2: If the frame size would result in the following frame being + # invalid. + if ($v2h->{major_version}==4 && + $id3v2_4_frame_size_broken == 0 && # we haven't detected brokenness yet + $size > 0x80+$hlen && # ignore frames that are too short to ever be wrong + $off + $size < $end) + { + + print "Frame size might not be valid ID3v2.4 (part 2); checking for following frame validity\n" if ($debug_24); + + my $morebytes = substr($wholetag, $off+$size, 4); + + if (! ($morebytes =~ /^([A-Z0-9]{4})/ || $morebytes =~ /^\x00{4}/) ) { + + # The next tag cannot be valid because its name is wrong, which means that + # either the size must be invalid or the next frame truely is broken. + # Either way, we can try to reduce the size to see. + my $retrysize; + + print " following frame isn't valid using spec\n" if ($debug_24); + + $retrysize = $size - $hlen; # remove already added header length + $retrysize = (($retrysize & 0x0000007f)) | + (($retrysize & 0x00003f80)<<1) | + (($retrysize & 0x001fc000)<<2) | + (($retrysize & 0x0fe00000)<<3); # convert spec to non-spec sizes + + $retrysize += $hlen; # and re-add header len so that the entire frame's size is known + + if (length($wholetag) >= ($off+$retrysize+4)) { + + $morebytes = substr($wholetag, $off+$retrysize, 4); + + } else { + + $morebytes = ''; + } + + if (! ($morebytes =~ /^([A-Z0-9]{4})/ || + $morebytes =~ /^\x00{4}/ || + $off + $retrysize > $end) ) + { + # With the retry at the smaller size, the following frame still isn't valid + # so the only thing we can assume is that this frame is just broken beyond + # repair. Give up right now - there's no way we can recover. + print " and isn't valid using broken-spec support; giving up\n" if ($debug_24); + return; + } + + print " but is fine with broken-spec support; reverting to broken behaviour\n" if ($debug_24); + + # We're happy that the non-spec size looks valid to lead us to the next frame. + # We might be wrong, generating false-positives, but that's really what you + # get for trying to handle applications that don't handle the spec properly - + # use something that isn't broken. + # (this is a copy of the recovery code in part 1) + $size = $retrysize; + $bytesize = 128; + $id3v2_4_frame_size_broken = 1; + + } else { + + print " looks like valid following frame; keeping spec behaviour\n" if ($debug_24); + + } + } + my $flags = {}; - if ($v2h->{major_version} > 3) { + + # JRF: was > 3, but that's not true; future versions may be incompatible + if ($v2h->{major_version} == 4) { my @bits = split //, unpack 'B16', substr($bytes, 8, 2); + $flags->{frame_zlib} = $bits[12]; # JRF: need to know about compressed + $flags->{frame_encrypt} = $bits[13]; # JRF: ... and encrypt $flags->{frame_unsync} = $bits[14]; $flags->{data_len_indicator} = $bits[15]; } - return($id, $size, $flags); + # JRF: version 3 was in a different order + elsif ($v2h->{major_version} == 3) { + my @bits = split //, unpack 'B16', substr($bytes, 8, 2); + $flags->{frame_zlib} = $bits[8]; # JRF: need to know about compressed + $flags->{data_len_indicator} = $bits[8]; # JRF: and compression implies the DLI is present + $flags->{frame_encrypt} = $bits[9]; # JRF: ... and encrypt + } + + return ($id, $size, $flags); }; while ($off < $end) { - my($id, $size, $flags) = &$myseek or last; + my ($id, $size, $flags) = &$myseek or last; + my ($hlenextra) = 0; - my $bytes = substr($wholetag, $off+$hlen, $size-$hlen); + # NOTE: Wrong; the encrypt comes after the DLI. maybe. + # JRF: Encrypted frames need to be decrypted first + if ($flags->{frame_encrypt}) { + + my ($encypt_method) = substr($wholetag, $off+$hlen+$hlenextra, 1); + + $hlenextra++; + + # We don't actually know how to decrypt anything, so we'll just skip the entire frame. + $off += $size; + + next; + } + + my $bytes = substr($wholetag, $off+$hlen+$hlenextra, $size-$hlen-$hlenextra); my $data_len; if ($flags->{data_len_indicator}) { $data_len = 0; + my @data_len_bytes = reverse unpack 'C4', substr($bytes, 0, 4); + $bytes = substr($bytes, 4); + for my $i (0..3) { $data_len += $data_len_bytes[$i] * 128 ** $i; } } + print "got $id, length " . length($bytes) . " frameunsync: ".$flags->{frame_unsync}." tag unsync: ".$v2h->{unsync} ."\n" if ($debug_24); + # perform frame-level unsync if needed (skip if already done for whole tag) $bytes =~ s/\xFF\x00/\xFF/gs if $flags->{frame_unsync} && !$v2h->{unsync}; + # JRF: Decompress now if compressed. + # (FIXME: Not implemented yet) + # if we know the data length, sanity check it now. if ($flags->{data_len_indicator} && defined $data_len) { - carp "Size mismatch on $id\n" unless $data_len == length($bytes); + carp("Size mismatch on $id\n") unless $data_len == length($bytes); + } + + # JRF: Apply small sanity check on text elements - they must end with : + # a 0 if they are ISO8859-1 + # 0,0 if they are unicode + # (This is handy because it can be caught by the 'duplicate elements' + # in array checks) + # There is a question in my mind whether I should be doing this here - it + # is introducing knowledge of frame content format into the raw reader + # which is not a good idea. But if the frames are broken we at least + # recover. + if (($v2h->{major_version} == 3 || $v2h->{major_version} == 4) && $id =~ /^T/) { + + my $encoding = substr($bytes, 0, 1); + + # Both these cases are candidates for providing some warning, I feel. + # ISO-8859-1 or UTF-8 $bytes + if (($encoding eq "\x00" || $encoding eq "\x03") && $bytes !~ /\x00$/) { + + $bytes .= "\x00"; + print "Text frame $id has malformed ISO-8859-1/UTF-8 content\n" if ($debug_Tencoding); + + # # UTF-16, UTF-16BE + } elsif ( ($encoding eq "\x01" || $encoding eq "\x02") && $bytes !~ /\x00\x00$/) { + + $bytes .= "\x00\x00"; + print "Text frame $id has malformed UTF-16/UTF-16BE content\n" if ($debug_Tencoding); + + } else { + + # Other encodings cannot be fixed up (we don't know how 'cos they're not defined). + } } if (exists $v2->{$id}) { + if (ref $v2->{$id} eq 'ARRAY') { push @{$v2->{$id}}, $bytes; } else { $v2->{$id} = [$v2->{$id}, $bytes]; } + } else { + $v2->{$id} = $bytes; } + $off += $size; } - return($v2, $v2h); -} + if (($ver == 0 || $ver == 2) && $v2) { + if ($raw == 1 && $ver == 2) { + + %$info = %$v2; + + $info->{'TAGVERSION'} = $v2h->{'version'}; + + } else { + + _parse_v2tag($ver, $raw, $v2, $info); + + if ($ver == 0 && $info->{'TAGVERSION'}) { + $info->{'TAGVERSION'} .= ' / ' . $v2h->{'version'}; + } else { + $info->{'TAGVERSION'} = $v2h->{'version'}; + } + } + } + + return 1; +} =pod @@ -1104,15 +1573,17 @@ sub get_mp3info { $@ = "No file specified"; return undef; } - - if (not -s $file) { - $@ = "File is empty"; - return undef; - } + + my $size = -s $file; if (ref $file) { # filehandle passed $fh = $file; } else { + if ( !$size ) { + $@ = "File is empty"; + return undef; + } + if (not open $fh, '<', $file) { $@ = "Can't open $file: $!"; return undef; @@ -1128,15 +1599,19 @@ sub get_mp3info { } binmode $fh; - seek $fh, $off, 0; + seek $fh, $off, SEEK_SET; read $fh, $byte, 4; - if ($off == 0) { - if (my $v2h = _get_v2head($fh)) { - $tot += $off += $v2h->{tag_size}; - seek $fh, $off, 0; - read $fh, $byte, 4; + if (my $v2h = _get_v2head($fh)) { + $tot += $off += $v2h->{tag_size}; + + if ( $off > $size - 10 ) { + # Invalid v2 tag size + $off = 0; } + + seek $fh, $off, SEEK_SET; + read $fh, $byte, 4; } $h = _get_head($byte); @@ -1147,7 +1622,7 @@ sub get_mp3info { # do only one read - it's _much_ faster $off++; - seek $fh, $off, 0; + seek $fh, $off, SEEK_SET; read $fh, $byte, $tot; my $i; @@ -1176,24 +1651,34 @@ sub get_mp3info { return undef; } } + + $h->{offset} = $off; - my $vbr = _get_vbr($fh, $h, \$off); - - seek $fh, 0, 2; + my $vbr = _get_vbr($fh, $h, \$off); + my $lame = _get_lame($fh, $h, \$off); + + seek $fh, 0, SEEK_END; $eof = tell $fh; - seek $fh, -128, 2; + seek $fh, -128, SEEK_END; $eof -= 128 if <$fh> =~ /^TAG/ ? 1 : 0; + # JRF: Check for an ID3v2.4 footer and if present, remove it from + # the size. + seek($fh, $eof, SEEK_SET); + + if (my $v2f = _get_v2foot($fh)) { + $eof -= $v2f->{tag_size}; + } + _close($file, $fh); $h->{size} = $eof - $off; - $h->{offset} = $off; - return _get_info($h, $vbr); + return _get_info($h, $vbr, $lame); } sub _get_info { - my($h, $vbr) = @_; + my($h, $vbr, $lame) = @_; my $i; # No bitrate or sample rate? Something's wrong. @@ -1201,26 +1686,30 @@ sub _get_info { return {}; } - $i->{VERSION} = $h->{IDR} == 2 ? 2 : $h->{IDR} == 3 ? 1 : - $h->{IDR} == 0 ? 2.5 : 0; + $i->{VERSION} = $h->{IDR} == 2 ? 2 : $h->{IDR} == 3 ? 1 : $h->{IDR} == 0 ? 2.5 : 0; $i->{LAYER} = 4 - $h->{layer}; - $i->{VBR} = defined $vbr ? 1 : 0; + + if (ref($vbr) eq 'HASH' and $vbr->{is_vbr} == 1) { + $i->{VBR} = 1; + } else { + $i->{VBR} = 0; + } $i->{COPYRIGHT} = $h->{copyright} ? 1 : 0; $i->{PADDING} = $h->{padding_bit} ? 1 : 0; $i->{STEREO} = $h->{mode} == 3 ? 0 : 1; $i->{MODE} = $h->{mode}; - $i->{SIZE} = $vbr && $vbr->{bytes} ? $vbr->{bytes} : $h->{size}; + $i->{SIZE} = $i->{VBR} == 1 && $vbr->{bytes} ? $vbr->{bytes} : $h->{size}; $i->{OFFSET} = $h->{offset}; my $mfs = $h->{fs} / ($h->{ID} ? 144000 : 72000); - $i->{FRAMES} = int($vbr && $vbr->{frames} + $i->{FRAMES} = int($i->{VBR} == 1 && $vbr->{frames} ? $vbr->{frames} : $i->{SIZE} / ($h->{bitrate} / $mfs) ); - if ($vbr) { + if ($i->{VBR} == 1) { $i->{VBR_SCALE} = $vbr->{scale} if $vbr->{scale}; $h->{bitrate} = $i->{SIZE} / $i->{FRAMES} * $mfs; if (not $h->{bitrate}) { @@ -1242,6 +1731,10 @@ sub _get_info { # should we just return if ! FRAMES? $i->{FRAME_LENGTH} = int($h->{size} / $i->{FRAMES}) if $i->{FRAMES}; $i->{FREQUENCY} = $frequency_tbl[3 * $h->{IDR} + $h->{sampling_freq}]; + + if ($lame) { + $i->{LAME} = $lame; + } return $i; } @@ -1306,15 +1799,16 @@ sub _vbr_seek { my $bytes = shift; my $n = shift || 4; - seek $fh, $$off, 0; + seek $fh, $$off, SEEK_SET; read $fh, $$bytes, $n; $$off += $n; } sub _get_vbr { - my($fh, $h, $roff) = @_; - my($off, $bytes, @bytes, %vbr); + my ($fh, $h, $roff) = @_; + my ($off, $bytes, @bytes); + my %vbr = (is_vbr => 0); $off = $$roff; @@ -1327,68 +1821,133 @@ sub _get_vbr { } _vbr_seek($fh, \$off, \$bytes); - return unless $bytes eq 'Xing'; - _vbr_seek($fh, \$off, \$bytes); - $vbr{flags} = _unpack_head($bytes); + if ($bytes =~ /(?:Xing|Info)/) { + # Info is CBR + $vbr{is_vbr} = 1 if $bytes =~ /Xing/; - if ($vbr{flags} & 1) { _vbr_seek($fh, \$off, \$bytes); - $vbr{frames} = _unpack_head($bytes); - } + $vbr{flags} = _unpack_head($bytes); + + if ($vbr{flags} & 1) { + _vbr_seek($fh, \$off, \$bytes); + $vbr{frames} = _unpack_head($bytes); + } + + if ($vbr{flags} & 2) { + _vbr_seek($fh, \$off, \$bytes); + $vbr{bytes} = _unpack_head($bytes); + } + + if ($vbr{flags} & 4) { + _vbr_seek($fh, \$off, \$bytes, 100); + # Not used right now ... + #$vbr{toc} = _unpack_head($bytes); + } + + if ($vbr{flags} & 8) { # (quality ind., 0=best 100=worst) + _vbr_seek($fh, \$off, \$bytes); + $vbr{scale} = _unpack_head($bytes); + } else { + $vbr{scale} = -1; + } - if ($vbr{flags} & 2) { + $$roff = $off; + } elsif ($bytes =~ /(?:VBRI)/) { + $vbr{is_vbr} = 1; + + # Fraunhofer encoder uses VBRI format + # start with quality factor at position 8 + _vbr_seek($fh, \$off, \$bytes, 4); + _vbr_seek($fh, \$off, \$bytes, 2); + $vbr{scale} = unpack('l', pack('L', unpack('n', $bytes))); + + # Then Bytes, as position 10 _vbr_seek($fh, \$off, \$bytes); $vbr{bytes} = _unpack_head($bytes); - } - if ($vbr{flags} & 4) { - _vbr_seek($fh, \$off, \$bytes, 100); -# Not used right now ... -# $vbr{toc} = _unpack_head($bytes); - } - - if ($vbr{flags} & 8) { # (quality ind., 0=best 100=worst) + # Finally Frames at position 14 _vbr_seek($fh, \$off, \$bytes); - $vbr{scale} = _unpack_head($bytes); - } else { - $vbr{scale} = -1; + $vbr{frames} = _unpack_head($bytes); + + $$roff = $off; } - $$roff = $off; return \%vbr; } +# Read LAME info tag +# http://gabriel.mp3-tech.org/mp3infotag.html +sub _get_lame { + my($fh, $h, $roff) = @_; + + my($off, $bytes, @bytes, %lame); + + $off = $$roff; + + # Encode version, 9 bytes + _vbr_seek($fh, \$off, \$bytes, 9); + $lame{encoder_version} = $bytes; + + return unless $bytes =~ /^LAME/; + + # There's some stuff here but it's not too useful + _vbr_seek($fh, \$off, \$bytes, 12); + + # Encoder delays (used for gapless decoding) + _vbr_seek($fh, \$off, \$bytes, 3); + my $bin = unpack 'B*', $bytes; + $lame{start_delay} = unpack('N', pack('B32', substr('0' x 32 . substr($bin, 0, 12), -32))); + $lame{end_padding} = unpack('N', pack('B32', substr('0' x 32 . substr($bin, 12, 12), -32))); + + return \%lame; +} + +# _get_v2head(file handle, start offset in file); +# The start offset can be used to check ID3v2 headers anywhere +# in the MP3 (eg for 'update' frames). sub _get_v2head { my $fh = $_[0] or return; - my($v2h, $bytes, @bytes); - $v2h->{offset} = 0; + + my $v2h = { + 'offset' => $_[1] || 0, + 'tag_size' => 0, + }; # check first three bytes for 'ID3' - seek $fh, 0, 0; - read $fh, $bytes, 3; + seek($fh, $v2h->{offset}, SEEK_SET); + read($fh, my $header, 10); - # TODO: add support for tags at the end of the file - if ($bytes eq 'RIF' || $bytes eq 'FOR') { - _find_id3_chunk($fh, $bytes) or return; - $v2h->{offset} = tell $fh; - read $fh, $bytes, 3; + my $tag = substr($header, 0, 3); + + # (Note: Footers are dealt with in v2foot) + if ($v2h->{offset} == 0) { + + # JRF: Only check for special headers if we're at the start of the file. + if ($tag eq 'RIF' || $tag eq 'FOR') { + _find_id3_chunk($fh, $tag) or return; + $v2h->{offset} = tell $fh; + + read($fh, $header, 10); + $tag = substr($header, 0, 3); + } } - return unless $bytes eq 'ID3'; + return if $tag ne 'ID3'; # get version - read $fh, $bytes, 2; - $v2h->{version} = sprintf "ID3v2.%d.%d", - @$v2h{qw[major_version minor_version]} = - unpack 'c2', $bytes; + my ($major, $minor, $flags) = unpack ("x3CCC", $header); + + $v2h->{version} = sprintf("ID3v2.%d.%d", $major, $minor); + $v2h->{major_version} = $major; + $v2h->{minor_version} = $minor; # get flags - read $fh, $bytes, 1; - my @bits = split //, unpack 'b8', $bytes; + my @bits = split(//, unpack('b8', pack('v', $flags))); + if ($v2h->{major_version} == 2) { $v2h->{unsync} = $bits[7]; - $v2h->{compression} = $bits[8]; + $v2h->{compression} = $bits[6]; # Should be ignored - no defined form $v2h->{ext_header} = 0; $v2h->{experimental} = 0; } else { @@ -1399,8 +1958,132 @@ sub _get_v2head { } # get ID3v2 tag length from bytes 7-10 - $v2h->{tag_size} = 10; # include ID3v2 header size + my $rawsize = substr($header, 6, 4); + + for my $b (unpack('C4', $rawsize)) { + + $v2h->{tag_size} = ($v2h->{tag_size} << 7) + $b; + } + + $v2h->{tag_size} += 10; # include ID3v2 header size $v2h->{tag_size} += 10 if $v2h->{footer}; + + # JRF: I think this is done wrongly - this should be part of the main frame, + # and therefore under ID3v2.3 it's subject to unsynchronisation + # (ID3v2.3, section 3.2). + # FIXME. + + # get extended header size (2.3/2.4 only) + $v2h->{ext_header_size} = 0; + + if ($v2h->{ext_header}) { + my $filesize = -s $fh; + + read $fh, my $bytes, 4; + my @bytes = reverse unpack 'C4', $bytes; + + # use syncsafe bytes if using version 2.4 + my $bytesize = ($v2h->{major_version} > 3) ? 128 : 256; + for my $i (0..3) { + $v2h->{ext_header_size} += $bytes[$i] * $bytesize ** $i; + } + + # Bug 4486 + # Don't try to read past the end of the file if we have a + # bogus extended header size. + if (($v2h->{ext_header_size} - 10 ) > -s $fh) { + + return $v2h; + } + + # Read the extended header + my $ext_data; + if ($v2h->{major_version} == 3) { + # On ID3v2.3 the extended header size excludes the whole header + read $fh, $bytes, 6 + $v2h->{ext_header_size}; + my @bits = split //, unpack 'b16', substr $bytes, 0, 2; + $v2h->{crc_present} = $bits[15]; + my $padding_size; + for my $i (0..3) { + + if (defined $bytes[2 + $i]) { + $padding_size += $bytes[2 + $i] * $bytesize ** $i; + } + } + $ext_data = substr $bytes, 6, $v2h->{ext_header_size} - $padding_size; + } + elsif ($v2h->{major_version} == 4) { + # On ID3v2.4, the extended header size includes the whole header + read $fh, $bytes, $v2h->{ext_header_size} - 4; + my @bits = split //, unpack 'b8', substr $bytes, 5, 1; + $v2h->{update} = $bits[6]; + $v2h->{crc_present} = $bits[5]; + $v2h->{tag_restrictions} = $bits[4]; + $ext_data = substr $bytes, 2, $v2h->{ext_header_size} - 6; + } + + # JRF: I'm not actually working out what the CRC or the tag + # restrictions are just yet. It doesn't seem to be + # all that worthwhile. + # However, if this is implemented... + # Under ID3v2.3, the CRC is not sync-safe (4 bytes). + # Under ID3v2.4, the CRC is sync-safe (5 bytes, excluding the flag data + # length) + # Under ID3v2.4, every flag byte that's set is given a flag data byte + # in the extended data area, the first byte of which is the size of + # the flag data (see ID3v2.4 section 3.2). + } + + return $v2h; +} + +# JRF: We assume that we have seeked to the expected EOF (ie start of the ID3v1 tag) +# The 'offset' value will hold the start of the ID3v1 header (NOT the footer) +# The 'tag_size' value will hold the entire tag size, including the footer. +sub _get_v2foot { + my $fh = $_[0] or return; + my($v2h, $bytes, @bytes); + my $eof; + + $eof = tell $fh; + + # check first three bytes for 'ID3' + seek $fh, $eof-10, SEEK_SET; # back 10 bytes for footer + read $fh, $bytes, 3; + + return undef unless $bytes eq '3DI'; + + # get version + read $fh, $bytes, 2; + $v2h->{version} = sprintf "ID3v2.%d.%d", + @$v2h{qw[major_version minor_version]} = + unpack 'c2', $bytes; + + # get flags + read $fh, $bytes, 1; + my @bits = split //, unpack 'b8', $bytes; + if ($v2h->{major_version} != 4) { + # JRF: This should never happen - only v4 tags should have footers. + # Think about raising some warnings or something ? + # print STDERR "Invalid ID3v2 footer version number\n"; + } else { + $v2h->{unsync} = $bits[7]; + $v2h->{ext_header} = $bits[6]; + $v2h->{experimental} = $bits[5]; + $v2h->{footer} = $bits[4]; + if (!$v2h->{footer}) + { + # JRF: This is an invalid footer marker; it doesn't make sense + # for the footer to not be marked as the tag having a footer + # so strictly it's an invalid tag. + # A warning might be nice, but for now we'll ignore. + # print STDERR "Warning: Footer doesn't have footer bit set\n"; + } + } + + # get ID3v2 tag length from bytes 7-10 + $v2h->{tag_size} = 10; # include ID3v2 header size + $v2h->{tag_size} += 10; # always account for the footer read $fh, $bytes, 4; @bytes = reverse unpack 'C4', $bytes; foreach my $i (0 .. 3) { @@ -1408,42 +2091,56 @@ sub _get_v2head { $v2h->{tag_size} += $bytes[$i] * 128 ** $i; } - # get extended header size - $v2h->{ext_header_size} = 0; - if ($v2h->{ext_header}) { - read $fh, $bytes, 4; - @bytes = reverse unpack 'C4', $bytes; + # Note that there are no extended header details on the footer; it's + # just a copy of it so that clients can seek backward to find the + # footer's start. - # use syncsafe bytes if using version 2.4 - my $bytesize = ($v2h->{major_version} > 3) ? 128 : 256; - for my $i (0..3) { - $v2h->{ext_header_size} += $bytes[$i] * $bytesize ** $i; - } + $v2h->{offset} = $eof - $v2h->{tag_size}; + + # Just to be really sure, read the start of the ID3v2.4 header here. + seek $fh, $v2h->{offset}, 0; # SEEK_SET + read $fh, $bytes, 3; + if ($bytes ne "ID3") { + # Not really an ID3v2.4 tag header; a warning would be nice but ignore + # for now. + # print STDERR "Invalid ID3v2 footer (header check) at " . $v2h->{offset} . "\n"; + return undef; } + # We could check more of the header. I'm not sure it's really worth it + # right now but at some point in the future checking the details match + # would be nice. + return $v2h; -} + +}; sub _find_id3_chunk { my($fh, $filetype) = @_; - my($bytes, $size, $tag, $pat, $mat); + my($bytes, $size, $tag, $pat, @mat); - read $fh, $bytes, 1; + # CHANGE 10616 introduced a read optimization in _get_v2head: + # 10 bytes are read, not 3, so reading one here hoping to get the last letter of the + # tag is a bad idea, as it always fails... + +# read $fh, $bytes, 1; if ($filetype eq 'RIF') { # WAV - return 0 if $bytes ne 'F'; +# return 0 if $bytes ne 'F'; $pat = 'a4V'; - $mat = 'id3 '; + @mat = ('id3 ', 'ID32'); } elsif ($filetype eq 'FOR') { # AIFF - return 0 if $bytes ne 'M'; +# return 0 if $bytes ne 'M'; $pat = 'a4N'; - $mat = 'ID3 '; + @mat = ('ID3 ', 'ID32'); } - seek $fh, 12, 0; # skip to the first chunk + seek $fh, 12, SEEK_SET; # skip to the first chunk while ((read $fh, $bytes, 8) == 8) { ($tag, $size) = unpack $pat, $bytes; - return 1 if $tag eq $mat; - seek $fh, $size, 1; + for my $mat ( @mat ) { + return 1 if $tag eq $mat; + } + seek $fh, $size, SEEK_CUR; } return 0; @@ -1455,7 +2152,7 @@ sub _unpack_head { sub _grab_int_16 { my $data = shift; - my $value = unpack('s',substr($$data,0,2)); + my $value = unpack('s', pack('S', unpack('n',substr($$data,0,2)))); $$data = substr($$data,2); return $value; } @@ -1474,44 +2171,91 @@ sub _grab_int_32 { return $value; } +# From getid3 - lyrics +# +# Just get the size and offset, so the APE tag can be parsed. +sub _parse_lyrics3_tag { + my ($fh, $filesize, $info) = @_; + + # end - ID3v1 - LYRICSEND - [Lyrics3size] + seek($fh, (0 - 128 - 9 - 6), SEEK_END); + read($fh, my $lyrics3_id3v1, 128 + 9 + 6); + + my $lyrics3_lsz = substr($lyrics3_id3v1, 0, 6); # Lyrics3size + my $lyrics3_end = substr($lyrics3_id3v1, 6, 9); # LYRICSEND or LYRICS200 + my $id3v1_tag = substr($lyrics3_id3v1, 15, 128); # ID3v1 + + my ($lyrics3_size, $lyrics3_offset, $lyrics3_version); + + # Lyrics3v1, ID3v1, no APE + if ($lyrics3_end eq 'LYRICSEND') { + + $lyrics3_size = 5100; + $lyrics3_offset = $filesize - 128 - $lyrics3_size; + $lyrics3_version = 1; + + } elsif ($lyrics3_end eq 'LYRICS200') { + + # Lyrics3v2, ID3v1, no APE + # LSZ = lyrics + 'LYRICSBEGIN'; add 6-byte size field; add 'LYRICS200' + $lyrics3_size = $lyrics3_lsz + 6 + length('LYRICS200'); + $lyrics3_offset = $filesize - 128 - $lyrics3_size; + $lyrics3_version = 2; + + } elsif (substr(reverse($lyrics3_id3v1), 0, 9) eq 'DNESCIRYL') { + + # Lyrics3v1, no ID3v1, no APE + $lyrics3_size = 5100; + $lyrics3_offset = $filesize - $lyrics3_size; + $lyrics3_version = 1; + $lyrics3_offset = $filesize - $lyrics3_size; + + } elsif (substr(reverse($lyrics3_id3v1), 0, 9) eq '002SCIRYL') { + + # Lyrics3v2, no ID3v1, no APE + # LSZ = lyrics + 'LYRICSBEGIN'; add 6-byte size field; add 'LYRICS200' > 15 = 6 + strlen('LYRICS200') + $lyrics3_size = reverse(substr(reverse($lyrics3_id3v1), 9, 6)) + 15; + $lyrics3_offset = $filesize - $lyrics3_size; + $lyrics3_version = 2; + } + + return $lyrics3_offset; +} + sub _parse_ape_tag { my ($fh, $filesize, $info) = @_; - my $ape_tag_id = 'APETAGEX'; - - seek $fh, -256, 2; - read($fh, my $tag, 256); - my $pre_tag = substr($tag, 0, 128, ''); - - # Try and bail early if there's no ape tag. - if (substr($pre_tag, 96, 8) ne $ape_tag_id && substr($tag, 96, 8) ne $ape_tag_id) { - - seek($fh, 0, 0); - return 0; - } - + my $ape_tag_id = 'APETAGEX'; my $id3v1_tag_size = 128; my $ape_tag_header_size = 32; my $lyrics3_tag_size = 10; my $tag_offset_start = 0; my $tag_offset_end = 0; - seek($fh, (0 - $id3v1_tag_size - $ape_tag_header_size - $lyrics3_tag_size), 2); + if (my $offset = _parse_lyrics3_tag($fh, $filesize, $info)) { - read($fh, my $ape_footer_id3v1, $id3v1_tag_size + $ape_tag_header_size + $lyrics3_tag_size); + seek($fh, $offset - $ape_tag_header_size, SEEK_SET); + $tag_offset_end = $offset; - if (substr($ape_footer_id3v1, (length($ape_footer_id3v1) - $id3v1_tag_size - $ape_tag_header_size), 8) eq $ape_tag_id) { + } else { - $tag_offset_end = $filesize - $id3v1_tag_size; + seek($fh, (0 - $id3v1_tag_size - $ape_tag_header_size - $lyrics3_tag_size), SEEK_END); - } elsif (substr($ape_footer_id3v1, (length($ape_footer_id3v1) - $ape_tag_header_size), 8) eq $ape_tag_id) { + read($fh, my $ape_footer_id3v1, $id3v1_tag_size + $ape_tag_header_size + $lyrics3_tag_size); - $tag_offset_end = $filesize; + if (substr($ape_footer_id3v1, (length($ape_footer_id3v1) - $id3v1_tag_size - $ape_tag_header_size), 8) eq $ape_tag_id) { + + $tag_offset_end = $filesize - $id3v1_tag_size; + + } elsif (substr($ape_footer_id3v1, (length($ape_footer_id3v1) - $ape_tag_header_size), 8) eq $ape_tag_id) { + + $tag_offset_end = $filesize; + } + + seek($fh, $tag_offset_end - $ape_tag_header_size, SEEK_SET); } - seek($fh, $tag_offset_end - $ape_tag_header_size, 0); - - read($fh, my $ape_footer_data, 32); + read($fh, my $ape_footer_data, $ape_tag_header_size); my $ape_footer = _parse_ape_header_or_footer($ape_footer_data); @@ -1521,7 +2265,7 @@ sub _parse_ape_tag { if ($ape_footer->{'flags'}->{'header'}) { - seek($fh, ($tag_offset_end - $ape_footer->{'tag_size'} - $ape_tag_header_size), 0); + seek($fh, ($tag_offset_end - $ape_footer->{'tag_size'} - $ape_tag_header_size), SEEK_SET); $tag_offset_start = tell($fh); @@ -1531,29 +2275,41 @@ sub _parse_ape_tag { $tag_offset_start = $tag_offset_end - $ape_footer->{'tag_size'}; - seek($fh, $tag_offset_start, 0); + seek($fh, $tag_offset_start, SEEK_SET); read($fh, $ape_tag_data, $ape_footer->{'tag_size'}); } my $ape_header_data = substr($ape_tag_data, 0, $ape_tag_header_size, ''); my $ape_header = _parse_ape_header_or_footer($ape_header_data); - - for (my $c = 0; $c < $ape_header->{'tag_items'}; $c++) { - # Loop through the tag items - my $tag_len = _grab_int_32(\$ape_tag_data); - my $tag_flags = _grab_int_32(\$ape_tag_data); + if ( defined $ape_header->{'version'} ) { + if ( $ape_header->{'version'} == 2000 ) { + $info->{'TAGVERSION'} = 'APEv2'; + } + else { + $info->{'TAGVERSION'} = 'APEv1'; + } + } - $ape_tag_data =~ s/^(.*?)\0//; + if (defined $ape_header->{'tag_items'} && $ape_header->{'tag_items'} =~ /^\d+$/) { - my $tag_item_key = uc($1 || 'UNKNOWN'); + for (my $c = 0; $c < $ape_header->{'tag_items'}; $c++) { + + # Loop through the tag items + my $tag_len = _grab_int_32(\$ape_tag_data); + my $tag_flags = _grab_int_32(\$ape_tag_data); - $info->{$tag_item_key} = substr($ape_tag_data, 0, $tag_len, ''); + $ape_tag_data =~ s/^(.*?)\0//; + + my $tag_item_key = uc($1 || 'UNKNOWN'); + + $info->{$tag_item_key} = substr($ape_tag_data, 0, $tag_len, ''); + } } } - seek($fh, 0, 0); + seek($fh, 0, SEEK_SET); return 1; } @@ -2076,6 +2832,7 @@ Tony Bowden, Tom Brown, Sergio Camarena, Chris Dawson, +Kevin Deane-Freeman, Anthony DiSante, Luke Drumm, Kyle Farrell, @@ -2083,6 +2840,7 @@ Jeffrey Friedl, brian d foy, Ben Gertzfield, Brian Goodwin, +Andy Grundman, Todd Hanneken, Todd Harris, Woodrow Hill, @@ -2120,12 +2878,12 @@ Ronan Waide, Andy Waite, Ken Williams, Ben Winslow, -Meng Weng Wong. - +Meng Weng Wong, +Justin Fletcher. =head1 CURRENT AUTHOR -Dan Sully Edan | at | slimdevices.comE & Slim Devices, Inc. +Dan Sully Edaniel | at | cpan.orgE & Logitech. =head1 AUTHOR EMERITUS @@ -2133,7 +2891,7 @@ Chris Nandor Epudge@pobox.comE, http://pudge.net/ =head1 COPYRIGHT AND LICENSE -Copyright (c) 2006 Dan Sully & Slim Devices, Inc. All rights reserved. +Copyright (c) 2006-2008 Dan Sully & Logitech. All rights reserved. Copyright (c) 1998-2005 Chris Nandor. All rights reserved. @@ -2144,7 +2902,7 @@ the same terms as Perl itself. =over 4 -=item Slim Devices +=item Logitech/Slim Devices http://www.slimdevices.com/