diff -pru another/work/Changes ./work/Changes --- another/work/Changes Wed Jan 21 15:12:18 2004 +++ ./work/Changes Mon Mar 22 17:58:40 2004 @@ -22,7 +22,8 @@ Changes: Thanks to Ilya Zakharevich for the following changes: -* Two new modules MP3::Tag::Inf and MP3::Tag::CDDB_File added +* Three new modules MP3::Tag::Inf, MP3::Tag::ParseData, and + MP3::Tag::CDDB_File added * Tag.pm: - autoinfo() method returns the info for all ID3v1 tags; @@ -34,13 +35,25 @@ Thanks to Ilya Zakharevich for the follo renamed to parse_filename(); backward compatible name still preserved; - config() subroutine can now change all the arbitrary decisions; - - new method interpolate() for tag interpolation into strings; - - new methods filename(), abs_filename() and filename_nodir(). + - new methods interpolate() for tag interpolation into strings and + parse(), parse_rex() for an inverse operation; + - new subpackage `__hasparent' to treat cyclic object dependencies; + - per-object configuration; + - new configuration options extension, parse_data, parse_split, + parse_filename_ignore_case, parse_filename_merge_dots, parse_join; + - a way to store and query user-supplied data inside the object (may + be used as scratch space when parsing); + - new methods get_config(), get_user(), set_user(), parse_prepare(), + parse_rex_prepare(), parse_rex_match(), filename(), abs_filename(), + filename_nodir(), filename_noextension(), filename_nodir_noextension(), + abs_filename_noextension(), dirname(), filename_extension(), + filename_extension_nodot(), dir_component(). * ID3v1.pm: - new ID3v1 method fits_tags() to check whether the info can be placed into an ID3v1 tag - If no track given use automatically ID3v1.0 tag with longer comment + - open() file if needed; do not close() close during new(). * ID3v2.pm: - allow multiple frames to be returned by get_frame() in ID3v2; @@ -48,6 +61,8 @@ Thanks to Ilya Zakharevich for the follo to get_frame); - get_frame_ids() in ID3v2 improved to take new argument 'truename' to simplify looping over repeated frames; + - simpler treatement of sync; fix uncorrect calculation of padding length; + - overwrite the tail of the previously present tag with 0s; - a couple of new v2.4 field names added. * File.pm: @@ -55,10 +70,15 @@ Thanks to Ilya Zakharevich for the follo appended (in parentheses) to author or title; filename can be F<.wav> as well. - support file names starting with track numbers as in 03_This_is_the_title.mp3 + - support track_title.ext and title_track.ext formats with one-word title too. + - new method filename(); + - use the 'extension' configuration variable to strip extension. * misprints in the docs corrected and some general harmonization of function and field names in the different modules +* new example script mp3info2 (which provides most of functionality of the + "standard" mp3info utility, and much more). Release Name: 0.40 ================== diff -pru another/work/data_pod.PL ./work/data_pod.PL --- another/work/data_pod.PL Mon Aug 6 12:56:32 2001 +++ ./work/data_pod.PL Fri Mar 12 00:28:18 2004 @@ -2,6 +2,7 @@ ## data_pod.PL creates the documentation File MP3::Tag::ID3v2-Data +use MP3::Tag; use MP3::Tag::ID3v2; $filename=shift || "./Tag/ID3v2-Data.pod"; diff -pru another/work/examples/mp3info2.pl ./work/examples/mp3info2.pl --- another/work/examples/mp3info2.pl Wed Jan 21 12:49:00 2004 +++ ./work/examples/mp3info2.pl Mon Mar 22 17:34:56 2004 @@ -1,13 +1,14 @@ #!/usr/bin/perl -w -use blib 'J:/test-programs-other/.cpan/tagged-0.40'; use MP3::Tag; use Getopt::Std 'getopts'; use strict; my %opt; -getopts('c:a:t:l:n:g:y:uDp:C:', \%opt); +getopts('c:a:t:l:n:g:y:uDp:C:P:E:G@', \%opt); +exec 'perldoc', '-F', $0 unless @ARGV; +# keys of %opt to the MP3::Tag keywords: my %trans = ( 't' => 'title', 'a' => 'artist', 'l' => 'album', @@ -16,30 +17,62 @@ my %trans = ( 't' => 'title', 'c' => 'comment', 'n' => 'track' ); +# Interprete Escape sequences: +my %r = ( 'n' => "\n", 't' => "\t", '\\' => "\\" ); +for my $e (split //, (exists $opt{E} ? $opt{E} : 'p')) { + $opt{$e} =~ s/\\([nt\\])/$r{$1}/g if defined $opt{$e}; +} +if ($opt{'@'}) { + for my $k (keys %opt) { + $opt{$k} =~ s/\@/%/g; + } +} + # Configure stuff... -$opt{C} =~ s/^(\w+)=/$1,/, MP3::Tag->config(split /,/, $opt{C}) - if defined $opt{C}; +if (defined $opt{C}) { + my ($c) = ($opt{C} =~ /^(\W)/); + $c = quotemeta $c if defined $c; + $c = '(?!)' unless defined $c; # Never match + my @opts = split /$c/, $opt{C}; + shift @opts if @opts > 1; + for $c (@opts) { + $c =~ s/^(\w+)=/$1,/; + MP3::Tag->config(split /,/, $c); + } +} -my %r = ( 'n' => "\n", 't' => "\t", '\\' => "\\" ); -$opt{p} =~ s/\\([nt\\])/$r{$1}/g if defined $opt{p}; +my @parse_data; +if (defined $opt{P}) { + my ($c) = ($opt{P} =~ /^\w*(\W)/s); + $c = quotemeta $c if defined $c; + $c = '(?!)' unless defined $c; # Never match + @parse_data = map [split /$c/], split /$c$c$c/, $opt{P}; + for $c (@parse_data) { + die "Two few parts in parse directive `@$c'.\n" if @$c < 3; + } +} # E.g., to make Inf overwrite existing title, do # mp3info2.pl -C title,Inf,ID3v2,ID3v1,filename -u *.mp3 my $f; -for $f (@ARGV) { - print "File: $f\n" unless exists $opt{p}; - if (my $mp3=MP3::Tag->new($f)) { - print $mp3->interpolate(exists $opt{p} ? $opt{p} : <new($f); # BUGXXXX Can't merge into if(): extra refcount + if ($mp3) { + print $mp3->interpolate(<autoinfo('from'); my $modify; + my @args; for my $k (keys %trans) { if (exists $opt{$k}) { + push @args, ['mz', $opt{$k}, "%$k"]; if (exists $data->{$trans{$k}}) { if ( $data->{$trans{$k}}->[0] ne $opt{$k} or $data->{$trans{$k}}->[1] !~ /^id3/i ) { @@ -56,12 +89,37 @@ EOC } if ($opt{u} and not $modify) { # Update for my $k (keys %$data) { - next if $data->{$k}->[1] =~ /^ID3/; + next if $k eq 'song'; # Alias for title (otherwise double warn) + next if $data->{$k}->[1] =~ /^(ID3|cmd)/; + next unless defined $data->{$k}->[0]; + next unless length $data->{$k}->[0]; $modify = 1; warn "Need to propagate $k from $data->{$k}->[1]\n"; } } + my $odata = $data; + # Now, when we know what should be updated, retry with arguments + if (@args or @parse_data) { + $mp3 = MP3::Tag->new($f); + $mp3->config('parse_data', @parse_data, @args); + $data = $mp3->autoinfo('from'); + } + print $mp3->interpolate(exists $opt{p} ? $opt{p} : <{$k} and + (not defined $odata->{$k} or $data->{$k} ne $odata->{$k}); + } + } $opt{u} and warn "No update needed\n" unless $modify; next unless $modify and not $opt{D}; # Dry run @@ -89,16 +147,32 @@ EOC =head1 NAME -mp3info2 - get/set MP3 tags +mp3info2 - get/set MP3 tags; uses L to get default values. =head1 SYNOPSIS + # Print the information in tags and autodeduced info + mp3info2 *.mp3 + + # In addition, set the year field to 1981 mp3info2 -y 1981 *.mp3 + # Same without printout of information + mp3info2 -p "" -y 1981 *.mp3 + + # Do not deduce any field, print the info from the tags only + mp3info2 -C autoinfo=ID3v2,ID3v1 *.mp3 + + # Get the artist from CDDB_File, autodeduce other info, write it to tags mp3info2 -C artist=CDDB_File -u *.mp3 - mp3info2.pl -C title=Inf,ID3v2,ID3v1,filename -u *.mp3 + # For the title, prefer information from .inf file; autodeduce and update + mp3info2 -C title=Inf,ID3v2,ID3v1,filename -u *.mp3 + + # Same, and get the author from CDDB file + mp3info2 -C "#title=Inf,ID3v2,ID3v1,filename#artist=CDDB_File" -u *.mp3 + # Write a script for conversion of .wav to .mp3 autodeducing tags mp3info2 -p "lame -h --vbr-new --tt '%t' --tn %n --ta '%a' --tc '%c' --tl '%l' --ty '%y' '%f'\n" *.wav >xxx.sh =head1 DESCRIPTION @@ -128,13 +202,20 @@ C<-u> forces the update of the ID3 tags. needed. The option C<-C> sets C configuration data (separated by -commas) as L. +commas; the first comma can be replaced by C<=> sign) as MP3::Tag->config() +would do. (To call config() multiple times, separate the parts by arbitrary +non-alphanumeric character, and repeat this character in the start of C<-C> +option.) Note that since C is used to inject the user-specified +tag fields (such as C<-a "A. U. Thor">), usually it should be kept in the +C configuration (and related fields C etc). The option C<-u> writes (Cpdates) the fetched information to the MP3 ID3 -tags. +tags. This option is assumed if tag elements are set via command-line +options. (This option is overwritten by C<-D> option.) The option C<-p> prints a message using the next argument as format -(with C<\\>, C<\t>, C<\n> replaces by backslash, tab and newline); see +(by default C<\\>, C<\t>, C<\n> are replaced by backslash, tab and newline; +governed by the value of C<-E> option); see L for details of the format of sprintf()-like escapes. With option C<-D> (dry run) no update is performed. @@ -144,7 +225,137 @@ Use options t a l y g c n to overwrite the information (title artist album year genre comment -track-number) obtained via C heuristics (use with C<-u> switch). +track-number) obtained via C heuristics (C<-u> switch is implied +if any one of these arguments differs from what would be found otherwise; use +C<-D> switch to disable auto-update). -=cut +The option C<-P> should contain the parse recipes. They become the +configuration item C of C; eventually this information +is processed by L module. The option +is split into +C<[$flag, $string, @patterns]> on its +first non-alphanumeric character; if multiple options are needed, one should +separate +them by this character repeated 3 times. This data is processed by +L (if present in the chain of heuristics). + +If option C<-G> is specified, the file names on the command line are considered +as glob patterns. This may be useful if the maximal command-line length is too +low). + +The option C<-E> should contain the letters of the options where +C<\\, \n, \t> are interpolated (default: C

). If the option C<-@> is given, +all characters C<@> in the options are replaced by C<%>; this may be convenient +if the shell treats C<%> specially. + +=head1 EXAMPLES + +Only the C<-P> option is complicated enough to deserve comments... + +For a (silly) example, one can replace C<-a Homer -t Iliad> by + + -P mz=Homer=%a===mz=Iliad=%t + +A less silly example is forcing a particular way of parsing a file name via + + -P "im=%{d0}/%f=%a/%n %t.%e" +This interpolates the string C<"%{d0}/%f"> and parses the result (which is +the file name with one level of the directory part preserved) using the +pattern C<"%a/%n %t.%e">; thus the directory name becomes author, the leading +numeric part - the track number, and the rest of the file name (without +extension) - the title. Note that since multiple patterns are allowed, +one can similarly allow for multiple formats of the names, e.g. + + -P "im=%{d0}/%f=%a/%n %t.%e=%a/%t (%y).%e" + +allows for the file basename to be also of the form "TITLE (YEAR)". To give +more examples, + + -P "if=%D/.comment=%c" + +will read comment from the file F<.comment> in the directory of the audio file; + + -P "ifn=%D/.comment=%c" + +has similar effect if the file F<.comment> has one-line comments, one per +track (this assumes the the track number can be found by other means). + +Suppose that a file F in a directory of MP3 files has the following +format: it has a preamble, then has a short paragraph of information per +audio file, preceeded by the track number and dot: + + ... + + 12. Rezitativ. + (Pizarro, Rocco) + + 13. Duett: jetzt, Alter, jetzt hat es Eile, (Pizarro, Rocco) + + ... + +The following command puts this info into the title of the ID3 tag (provided +the audio file names are informative enough so that MP3::Tag can deduce the +track number): + + mp3info2 -u -C parse_split='\n(?=\d+\.)' -P 'fl;Parts;%=n. %t' + +If this paragraph of information has the form C with the +C<COMMENT> part being optional, then use + + mp3info2 -u -C parse_split='\n(?=\d+\.)' -P 'fl;Parts;%=n. %t (%c);%=n. %t' + +If you want to remove a dot or a comma got into the end of the title, use + + mp3info2 -u -C parse_split='\n(?=\d+\.)' \ + -P 'fl;Parts;%=n. %t (%c);%=n. %t;;;iR;%t;%t[.,]$' + +The second pattern of this invocation is converted to + + ['iR', '%t' => '%t[.,]$'] + +which essentially matches the title vs the substitution C<s/(.*)[.,]$/$1/s>. + +Now suppose that in addition to F<Parts>, we have a text file F<Comment> with +additional info; we want to put this info into the comment field I<after> +what is extracted from C<TITLE (COMMENT)>; separate these two parts of +the comment by an empty line: + + mp3info2 -E C -C '#parse_split=\n(?=\d+\.)#parse_join=\n\n' \ + -P 'f;Comment;%c;;;fl;Parts;%=n. %t;;;i;%t///%c;%t (%c)///%c;;;iR;%t;%t[.,]$' + +This assumes that the title and the comment do not contain C<'///'> as a +substring. Explanation: the first pattern of C<-P> reads comment from the +file C<Comment> into the comment field; the second reads a chunk of C<Parts> +into the title field. The third one + + ['i', '%t///%c' => '%t (%c)///%c'] + +rearranges the title and comment I<provided> the title is of the form C<TITLE +(COMMENT)>. (The configuration option C<parse_join> takes care of separating +two chunks of comment corresponding to two occurences of C<%c> on the right +hand side.) + +Finally, the fourth pattern is the same as in the preceeding example; it +removes spurious punctuation at the end of the title. + + mp3info2 -u -P 'i;%c///with piano;///%c' *.mp3 + mp3info2 -u -P 'iz;%c;with piano%c' *.mp3 + mp3info2 -C autoinfo=ParseData -a "A. U. Thor" *.mp3 + +Finish by a very simple example: all that the pattern + + -P 'i;%t;%t' + +does is removal of trailing and leading blanks from the title (deduced by +other means). + +=head1 AUTHOR + +Ilya Zakharevich <cpan@ilyaz.org>. + +=head1 SEE ALSO + +MP3::Tag, MP3::Tag::ParseData + +=cut diff -pru another/work/MANIFEST ./work/MANIFEST --- another/work/MANIFEST Wed Jan 21 12:49:00 2004 +++ ./work/MANIFEST Wed Mar 10 13:26:14 2004 @@ -9,14 +9,17 @@ Tag/ID3v1.pm Tag/ID3v2.pm Tag/Inf.pm Tag/CDDB_File.pm +Tag/ParseData.pm +cddb.tmp data_pod.PL examples/README.txt examples/extractID3v2.pl examples/mp3info.pl +examples/mp3info2.pl examples/tagged.pl examples/tagit.pl test.mp3 test.pl -test2.mp3 tk-tag/README tk-tag/tk-tag.pl +META.yml Module meta-data (added by MakeMaker) diff -pru another/work/Tag/CDDB_File.pm ./work/Tag/CDDB_File.pm --- another/work/Tag/CDDB_File.pm Wed Jan 21 12:49:00 2004 +++ ./work/Tag/CDDB_File.pm Tue Feb 10 21:22:40 2004 @@ -1,9 +1,12 @@ package MP3::Tag::CDDB_File; use strict; -use vars qw /$VERSION/; +use File::Basename; +use File::Spec; +use vars qw /$VERSION @ISA/; $VERSION="0.01"; +@ISA = 'MP3::Tag::__hasparent'; =pod @@ -42,25 +45,44 @@ sub new_from { bless {data => [split /\n/, $data], track => $track}, $class; } -sub new { +sub new_setdir { + my $class = shift; + my $filename = shift; + $filename = $filename->filename if ref $filename; + $filename = dirname($filename); + return bless {dir => $filename}, $class; # bless to enable get_config() +} + +sub new_fromdir { my $class = shift; - (my $filename = shift) =~ s/[^\/\\]*$//; # Allow short suffices + my $h = shift; + my $dir = $h->{dir}; my $found; - my $l = $MP3::Tag::config{cddb_files} || [qw/audio.cddb cddb.out cddb.in/]; + my $l = $h->get_config('cddb_files'); for my $file (@$l) { - $found = "$filename$file", last if -r "$filename$file"; + my $f = File::Spec->catdir($dir, $file); + $found = $f, last if -r $f; } return unless $found; local *F; open F, "< $found" or die "Can't open `$found': $!"; my @data = <F>; close F or die "Error closing `$found': $!"; - bless {data => \@data, track => shift}, $class; + bless {filename => $found, data => \@data, track => shift, + parent => $h->{parent}}, $class; +} + +sub new { + my $class = shift; + my $h = $class->new_setdir(@_); + $class->new_fromdir($h); } -sub new_guess_track { +sub new_with_parent { my ($class, $filename, $parent) = @_; - $class->new($filename, $parent->track || undef); + my $h = $class->new_setdir($filename); + $h->{parent} = $parent; + $class->new_fromdir($h); } # Destructor @@ -115,8 +137,9 @@ sub parse { $self->parse_lines; my %parsed; my ($t1, $c1, $t2, $c2) = map $self->{fields}{$_}, qw(DTITLE EXTD); - if ($self->{track}) { - my $t = $self->{track} - 1; + my $track = $self->track; + if ($track) { + my $t = $track - 1; ($t2, $c2) = map $self->{fields}{$_}, "TTITLE$t", "EXTT$t"; } my ($a, $t, $aa, $tt); @@ -139,7 +162,7 @@ sub parse { } } @parsed{ qw( title artist album year comment track genre) } = - ($tt, $aa, $t, $self->{fields}{DYEAR}, $c1, $self->{track}, + ($tt, $aa, $t, $self->{fields}{DYEAR}, $c1, $track, $self->{fields}{DGENRE}); $self->{parsed} = \%parsed; $self->return_parsed($what); @@ -183,12 +206,16 @@ sub artist { $track = $db->track(); -Returns the track number, obtained from the C<'Tracknumber'> entry of the file. +Returns the track number, stored during object creation. =cut sub track { - return shift->parse("track"); + my $self = shift; + return $self->{track} if defined $self->{track}; + return if $self->{recursive} or not $self->parent_ok; + local $self->{recursive} = 1; + return $self->{parent}->track; } =item year() diff -pru another/work/Tag/File.pm ./work/Tag/File.pm --- another/work/Tag/File.pm Wed Jan 21 15:11:50 2004 +++ ./work/Tag/File.pm Fri Feb 13 01:41:36 2004 @@ -2,9 +2,11 @@ package MP3::Tag::File; use strict; use Fcntl; -use vars qw /$VERSION/; +use File::Basename; +use vars qw /$VERSION @ISA/; $VERSION="0.40"; +@ISA = 'MP3::Tag::__hasparent'; =pod @@ -24,7 +26,9 @@ see L<MP3::Tag> MP3::Tag::File is designed to be called from the MP3::Tag module. -It offers possibilities to read/write data from files. +It offers possibilities to read/write data from files via read(), write(), +truncate(), seek(), tell(), open(), close(); one can find the filename via +the filename() method. =over 4 @@ -33,13 +37,12 @@ It offers possibilities to read/write da # Constructor -sub new { - my $class = shift; - my $self={filename=>shift}; - return undef unless -f $self->{filename}; - bless $self, $class; - return $self; +sub new_with_parent { + my ($class, $filename, $parent) = @_; + return undef unless -f $filename; + return bless {filename => $filename, parent => $parent}, $class; } +*new = \&new_with_parent; # Obsolete handler # Destructor @@ -52,6 +55,8 @@ sub DESTROY { # File subs +sub filename { shift->{filename} } + sub open { my $self=shift; my $mode= shift; @@ -62,11 +67,11 @@ sub open { } unless (exists $self->{FH}) { local *FH; - if (sysopen (FH, $self->{filename}, $mode)) { + if (sysopen (FH, $self->filename, $mode)) { $self->{FH} = *FH; binmode $self->{FH}; } else { - warn "Open $self->{filename} failed: $!\n"; + warn "Open $self->filename() failed: $!\n"; } } return exists $self->{FH}; @@ -226,25 +231,21 @@ sub return_parsed { sub parse_filename { my ($self,$what,$filename) = @_; - $filename = $self->{filename} unless defined $filename; + $filename = $self->filename unless defined $filename; my $pathandfile = $filename; $self->return_parsed($what) if exists $self->{parsed_filename} and $self->{parsed_filename} eq $filename; # prepare pathandfile for easier use - $pathandfile =~ s/\.(mp3|wav)$//i; # remove .mp3-extension + my $ext_rex = $self->get_config('extension')->[0]; + $pathandfile =~ s/$ext_rex//; # remove extension $pathandfile =~ s/ +/ /g; # replace several spaces by one space - # split pathandfile in path and file - my $file = $pathandfile; - $file =~ s/.*\\//; # for windows-filenames - $file =~ s/.*\///; # for unix-filenames + # Keep two last components of the file name + my ($file, $path) = fileparse($pathandfile, ""); + ($path) = fileparse($path, ""); my $orig_file = $file; - my $path = substr $pathandfile,0,length($pathandfile)-length($file); - chop $path; - $path =~ s/.*\\//; # for windows-filenames - $path =~ s/.*\///; # for unix-filenames # check which chars are used for seperating words # assumption: spaces between words @@ -298,12 +299,21 @@ sub parse_filename { $file =~ s/_+/ /g unless $partsep =~ /_/; #remove underscore unless they are needed for part seperation my @parts = split /$partsep/, $file; - if ($#parts==0) { + if (@parts == 1) { $title=$parts[0]; - } elsif ($#parts==1) { - $artist=$parts[0]; - $title=$parts[1]; - } elsif ($#parts>1) { + $no = $file if $title and $title =~ /^\d{1,2}$/; + } elsif (@parts == 2) { + if ($parts[0] =~ /^\d{1,2}$/) { + $no = $parts[0]; + $title = $file; + } elsif ($parts[1] =~ /^\d{1,2}$/) { + $no = $parts[1]; + $title = $file; + } else { + $artist=$parts[0]; + $title=$parts[1]; + } + } elsif (@parts > 2) { my $temp = ""; $artist = shift @parts; foreach (@parts) { @@ -322,12 +332,13 @@ sub parse_filename { $title =~ s/ +$//; $artist =~ s/ +$//; $no =~ s/ +$//; - $no =~ s/^0+//; # Special-case names like audio12 etc created by some software # (cdda2wav, gramofile, etc) $no = $+ if not $no and $title =~ /^(\d+)?(?:audio|track|processed)\s*(\d+)?$/i and $+; + $no =~ s/^0+//; + if ($path) { unless ($artist) { $artist = $path; diff -pru another/work/Tag/ID3v1.pm ./work/Tag/ID3v1.pm --- another/work/Tag/ID3v1.pm Wed Jan 21 15:06:00 2004 +++ ./work/Tag/ID3v1.pm Wed Feb 11 19:40:04 2004 @@ -7,9 +7,10 @@ package MP3::Tag::ID3v1; # with Perl. use strict; -use vars qw /@mp3_genres @winamp_genres $AUTOLOAD %ok_length $VERSION/; +use vars qw /@mp3_genres @winamp_genres $AUTOLOAD %ok_length $VERSION @ISA/; $VERSION="0.60"; +@ISA = 'MP3::Tag::__hasparent'; # allowed fields in ID3v1.1 and max length of this fields (except for track and genre which are coded later) %ok_length = (title => 30, artist => 30, album => 30, comment => 28, track => 3, genre => 30, year=>4, genreID=>1); @@ -229,7 +230,7 @@ sub write_tag { $mp3obj->write("TAG$data"); } } else { - warn "Couldn't open file to write tag"; + warn "Couldn't open file `" . $mp3obj->filename() . "' to write tag"; return 0; } return 1; @@ -323,7 +324,13 @@ returned, if now ID3v1 tag is found in t Please use $mp3 = MP3::Tag->new($filename); - $id3v1 = $mp3->new_tag($filename); + $id3v1 = $mp3->new_tag("ID3v1"); # Empty new tag + +or + + $mp3 = MP3::Tag->new($filename); + $mp3->get_tags(); + $id3v1 = $mp3->{ID3v1}; # Existing tag (if present) instead of using this function directly @@ -331,26 +338,29 @@ instead of using this function directly # create a ID3v1 object sub new { - my ($class, $mp3obj, $create) = @_; - my $self={mp3=>$mp3obj}; + my ($class, $fileobj, $create) = @_; + my $self={mp3=>$fileobj}; my $buffer; - if (defined $create && $create) { + if ($create) { $self->{new} = 1; } else { - $mp3obj->seek(-128,2); - $mp3obj->read(\$buffer, 128); - $mp3obj->close; + $fileobj->open or return unless $fileobj->is_open; + $fileobj->seek(-128,2); + $fileobj->read(\$buffer, 128); + return undef unless substr ($buffer,0,3) eq "TAG"; } - if (exists $self->{new} || substr ($buffer,0,3) eq "TAG") { - bless $self, $class; - $self->read_tag($buffer); - - return $self; - } else { - return undef; - } + bless $self, $class; + $self->read_tag($buffer); # $buffer unused if ->{new} + return $self; +} + +sub new_with_parent { + my ($class, $filename, $parent) = @_; + return unless my $new = $class->new($filename, undef); + $new->{parent} = $parent; + $new; } ################# @@ -360,7 +370,6 @@ sub new { # actually read the tag data sub read_tag { my ($self, $buffer) = @_; - my $mp3obj = $self->{mp3}; my $id3v1; if ($self->{new}) { diff -pru another/work/Tag/ID3v2.pm ./work/Tag/ID3v2.pm --- another/work/Tag/ID3v2.pm Wed Feb 4 15:10:32 2004 +++ ./work/Tag/ID3v2.pm Thu Feb 12 01:36:02 2004 @@ -10,9 +10,10 @@ use strict; use File::Basename; use Compress::Zlib; -use vars qw /%format %long_names %res_inp @supported_majors %v2names_to_v3 $VERSION/; +use vars qw /%format %long_names %res_inp @supported_majors %v2names_to_v3 $VERSION @ISA/; $VERSION="0.80"; +@ISA = 'MP3::Tag::__hasparent'; # ignore different $\ settings, otherwise tags may not be written correctly local $\=""; @@ -475,7 +476,7 @@ sub insert_space { $ins->[0] = $ins->[0]<16384?0:$ins->[0]-16384; } } - for (my $i; $i<$ins->[2]; $i++) { + for (my $i = 0; $i<$ins->[2]; $i++) { print NEW chr(0); } if ($ins->[1]) { @@ -568,28 +569,32 @@ sub write_tag { # perhaps search for first mp3 data frame to check if tag size is not # too big and will override the mp3 data - my $oldlen = length($tag_data); - $tag_data =~ s/\xFF\x00/\xFF\x00\x00/gos; - 1 while($tag_data =~ s/\xFF([\xE0-\xFF])/\xFF\x00$1/gos); - #ext header are not supported yet my $flags = chr(0); - $flags = chr(128) if length($tag_data) != $oldlen; # something to unsync was found, set unsync flag + $flags = chr(128) if $tag_data =~ s/\xFF(?=[\x00\xE0-\xFF])/\xFF\x00/g; # sync + my $taglen = length $tag_data; + my $header = 'ID3' . chr(3) . chr(0); # actually write the tag my $mp3obj = $self->{mp3}; - if (length ($tag_data) > $self->{tagsize}) { - # if creating new tag / increasing size add at least 2k padding - # add additional bytes to make new filesize multiple of 4k - my $filesize = (stat($mp3obj->{filename}))[7]; - my $padding = (($filesize+length($tag_data)+2048+4095) & 4096) - - $filesize - length($tag_data); - my @insert_space = ([0, $self->{tagsize}+10, length($tag_data) + $padding]); - return undef unless (insert_space($self, \@insert_space)==0); - $self->{tagsize} = length($tag_data) + $padding; - }; + my $padding = (length $tag_data and chr(0xFF) eq substr $tag_data, -1, 1); + my $padtail = $self->{tagsize} - length ($tag_data); + if ($padding > $padtail) { + # if creating new tag / increasing size add at least 2k padding + # add additional bytes to make new filesize multiple of 4k + my $filesize = (stat($mp3obj->{filename}))[7]; + my $newsize = ($filesize + $taglen - $self->{tagsize} + 0x800); + $newsize = (($newsize + 0xFFF) & ~0xFFF); + $padding = $newsize - $taglen - ($filesize - $self->{tagsize}); + my @insert_space = ([0, $self->{tagsize}+10, $taglen + $padding + 10]); + return undef unless (insert_space($self, \@insert_space)==0); + $padtail = 0; # 0s written by insert_space... + $self->{tagsize} = $taglen + $padding; + } else { # Keep tagsize + $padding = $padtail; # Automatically >=0 + } #convert size to header format specific size my $size = unpack('B32', pack ('N', $self->{tagsize})); @@ -603,6 +608,7 @@ sub write_tag { $mp3obj->write($flags); $mp3obj->write($size); $mp3obj->write($tag_data); + $mp3obj->write(chr(0) x $padtail) if $padtail; } else { warn "Couldn't open file write tag!"; return undef; @@ -909,6 +915,11 @@ empty string, the frame is removed. *song = \&title; +sub v2title_order { + my $self = shift; + @{ $self->get_config('v2title') }; +} + sub title { my $self = shift; if (@_) { @@ -916,13 +927,8 @@ sub title { return if @_ == 1 and $_[0] eq ''; return $self->add_frame('TIT2', @_); } - my @order; - if (exists $MP3::Tag::config{v2title}) { - @order = @{$MP3::Tag::config{v2title}}; - } else { - @order = ("TIT2"); - } - my @parts = grep defined && length, map scalar $self->get_frame($_), @order; + my @parts = grep defined && length, + map scalar $self->get_frame($_), $self->v2title_order; return unless @parts; my $last = pop @parts; my $part; @@ -954,7 +960,7 @@ sub _comment { or lc $comment->{Language} ne $language); return $comment->{Text}; } - return if grep $_ eq 'TIT3', @{$MP3::Tag::config{v2title} || []}; + return if grep $_ eq 'TIT3', $self->v2title_order; return scalar $self->get_frame("TIT3"); } @@ -984,9 +990,9 @@ sub comment { ++$c; next unless exists $comment->{short} and not length $comment->{short}; next if defined $language and (not exists $comment->{Language} - or lc $comment->{Language} ne $language); - $self->remove_frame($c ? 'COMM' : sprintf 'COMM%02d', $c); - $c--; + or lc $comment->{Language} ne lc $language); + $self->remove_frame($c ? sprintf 'COMM%02d', $c : 'COMM'); + # $c--; # Not needed if only one frame is removed last; } return if @_ == 1 and $_[0] eq ''; @@ -1088,7 +1094,7 @@ sub album { } my $a; ($a) = $self->get_frame("TALB") and return $a; - return if grep $_ eq 'TIT1', @{$MP3::Tag::config{v2title} || []}; + return if grep $_ eq 'TIT1', $self->v2title_order; return scalar $self->get_frame("TIT1"); } @@ -1164,6 +1170,7 @@ sub new { my $header=0; bless $self, $class; + $mp3obj->open or return unless $mp3obj->is_open; $mp3obj->seek(0,0); $mp3obj->read(\$header, 10); $self->{frame_start}=0; @@ -1214,6 +1221,13 @@ sub new { } } return undef; +} + +sub new_with_parent { + my ($class, $filename, $parent) = @_; + return unless my $new = $class->new($filename, undef); + $new->{parent} = $parent; + $new; } ################## diff -pru another/work/Tag/Inf.pm ./work/Tag/Inf.pm --- another/work/Tag/Inf.pm Wed Jan 21 12:49:00 2004 +++ ./work/Tag/Inf.pm Tue Feb 10 20:37:44 2004 @@ -1,9 +1,10 @@ package MP3::Tag::Inf; use strict; -use vars qw /$VERSION/; +use vars qw /$VERSION @ISA/; $VERSION="0.01"; +@ISA = 'MP3::Tag::__hasparent'; =pod @@ -14,6 +15,7 @@ MP3::Tag::Inf - Module for parsing F<.in =head1 SYNOPSIS my $mp3inf = MP3::Tag::Inf->new($filename); # Name of MP3 or .INF file + # or an MP3::Tag::File object ($title, $artist, $album, $year, $comment, $track) = $mp3inf->parse(); @@ -32,11 +34,16 @@ It parses the content of F<.inf> file (c # Constructor -sub new { - my $class = shift; - (my $filename = shift) =~ s/(?:\..{0,4})?$/.inf/; # Allow short suffices - return undef unless -f $filename; - bless {filename => $filename}, $class; +sub new_with_parent { + my ($class, $filename, $parent) = @_; + my $self = bless {parent => $parent}, $class; + + $filename = $filename->filename if ref $filename; + my $ext_rex = $self->get_config('extension')->[0]; + $filename =~ s/($ext_rex)|$/.inf/; # replace extension + return unless -f $filename; + $self->{filename} = $filename; + $self; } # Destructor @@ -119,97 +126,12 @@ sub parse { $self->return_parsed($what); } - -=pod - -=item title() - - $title = $mp3inf->title(); - -Returns the title, obtained from the C<'Tracktitle'> entry of the file. - -=cut - -*song = \&title; - -sub title { - return shift->parse("title"); -} - -=pod - -=item artist() - - $artist = $mp3inf->artist(); - -Returns the artist name, obtained from the C<'Performer'> or -C<'Albumperformer'> entries (the first which is present) of the file. - -=cut - -sub artist { - return shift->parse("artist"); -} - -=pod - -=item track() - - $track = $mp3inf->track(); - -Returns the track number, obtained from the C<'Tracknumber'> entry of the file. - -=cut - -sub track { - return shift->parse("track"); +for my $elt ( qw( title track artist album comment year genre ) ) { + no strict 'refs'; + *$elt = sub (;$) { + my $self = shift; + $self->parse($elt, @_); + } } - -=item year() - - $year = $mp3inf->year(); - -Returns the year, obtained from the C<'Year'> entry of the file. (Often -not present.) - -=cut - -sub year { - return shift->parse("year"); -} - -=pod - -=item album() - - $album = $mp3inf->album(); - -Returns the album name, obtained from the C<'Albumtitle'> entry of the file. - -=cut - -sub album { - return shift->parse("album"); -} - -=item comment() - - $comment = $mp3inf->comment(); - -Returns the C<'Trackcomment'> entry of the file. (Often not present.) - -=cut - -sub comment { - return shift->parse("comment"); -} - -=item genre() - - $genre = $mp3inf->genre($filename); # Always undef - -=cut - -sub genre {} 1; diff -pru another/work/Tag.pm ./work/Tag.pm --- another/work/Tag.pm Wed Jan 21 13:03:26 2004 +++ ./work/Tag.pm Tue Mar 16 18:52:18 2004 @@ -14,13 +14,38 @@ package MP3::Tag; use strict; use Cwd 'abs_path'; + +{ + package MP3::Tag::__hasparent; + sub parent_ok { + my $self = shift; + $self->{parent} and $self->{parent}->proxy_ok; + } + sub get_config { + my $self = shift; + return $MP3::Tag::config{shift()} unless $self->parent_ok; + return $self->{parent}->get_config(@_); + } +} + use MP3::Tag::ID3v1; use MP3::Tag::ID3v2; use MP3::Tag::File; use MP3::Tag::Inf; use MP3::Tag::CDDB_File; +use MP3::Tag::ParseData; use vars qw/$VERSION %config/; +%config = ( autoinfo => [qw(ParseData ID3v2 ID3v1 CDDB_File Inf filename)], + cddb_files => [qw(audio.cddb cddb.out cddb.in)], + v2title => [qw(TIT1 TIT2 TIT3)], + extension => ['\.(?!\d+\b)\w{1,4}$'], + parse_data => [], + parse_split => ["\n"], + parse_filename_ignore_case => [1], + parse_filename_merge_dots => [1], + parse_join => ['; '], + ); $VERSION="0.40"; @@ -99,21 +124,51 @@ sub new { my $class = shift; my $filename = shift; my $mp3data; + my $self = {}; + bless $self, $class; + my $proxy = MP3::Tag::__proxy->new($self); if (-f $filename) { - $mp3data = MP3::Tag::File->new($filename); + $mp3data = MP3::Tag::File->new_with_parent($filename, $proxy); } # later it should hopefully possible to support also http/ftp sources # with a MP3::Tag::Net module or something like that if ($mp3data) { - my $self={filename=>$mp3data, + %$self = (filename=>$mp3data, ofilename => $filename, - abs_filename => abs_path($filename)}; - bless $self, $class; + abs_filename => abs_path($filename), + __proxy => $proxy); return $self; } return undef; } +{ # Proxy class: to have only one place where to weaken/localize the reference + # $obj->[0] must be settable to the handle (not needed if weakening succeeds) + package MP3::Tag::__proxy; + use vars qw/$AUTOLOAD/; + + sub new { + my ($class, $handle) = (shift,shift); + my $self = bless [$handle], $class; + return bless [], $class + unless eval {require Scalar::Util; Scalar::Util::weaken($self->[0])}; + $self; + } + sub DESTROY {} + sub proxy_ok { shift->[0] } + sub AUTOLOAD { + my $self = shift; + die "local_proxy not initialized" unless $self->[0]; + (my $meth = $AUTOLOAD) =~ s/.*:://; + my $smeth = $self->[0]->can($meth); + die "proxy can't find the method $meth" unless $smeth; + unshift @_, $self->[0]; + goto &$smeth; + } +} + +sub proxy_ok { 1 } # We can always be a proxy to ourselves... ;-) + =pod =item get_tags() @@ -140,27 +195,15 @@ L<MP3::Tag::Inf>, L<MP3::Tag::CDDB_File> sub get_tags { my $self = shift; return @{$self->{gottags}} if exists $self->{gottags}; - my (@IDs, $ref); - if ($self->{filename}->open()) { - if (defined ($ref = MP3::Tag::ID3v2->new($self->{filename}))) { - $self->{ID3v2} = $ref; - push @IDs, "ID3v2"; - } - if(defined ($ref = MP3::Tag::ID3v1->new($self->{filename}))) { - $self->{ID3v1} = $ref; - push @IDs, "ID3v1"; - } - } - if ( not exists $self->{Inf} - and defined ($ref = MP3::Tag::Inf->new($self->{filename}{filename})) ) { - $self->{Inf} = $ref; - push @IDs, "Inf"; - } - $self->{gottags} = [@IDs]; # So "guess" can use collected info - if ( not exists $self->{CDDB_File} - and defined ($ref = MP3::Tag::CDDB_File->new_guess_track($self->{filename}{filename}, $self)) ) { - $self->{CDDB_File} = $ref; - push @IDs, "CDDB_File"; + my (@IDs, $id); + + # Will not create a reference loop + local $self->{__proxy}[0] = $self unless $self->{__proxy}[0]; + for $id (qw(ParseData ID3v2 ID3v1 Inf CDDB_File)) { + my $ref = "MP3::Tag::$id"->new_with_parent($self->{filename}, $self->{__proxy}); + next unless defined $ref; + $self->{$id} = $ref; + push @IDs, $id; } $self->{gottags} = [@IDs]; return @IDs; @@ -338,15 +381,16 @@ filename) from which the value is taken. sub auto_field($;$) { my ($self, $elt, $from) = (shift, shift, shift); + local $self->{__proxy}[0] = $self unless $self->{__proxy}[0]; - my $parts = $config{$elt} || $config{autoinfo} - || [qw(ID3v2 ID3v1 CDDB_File Inf filename)]; + my $parts = $self->get_config($elt) || $self->get_config('autoinfo'); $self->get_tags; foreach my $part (@$parts) { next unless exists $self->{$part}; next unless defined (my $out = $self->{$part}->$elt()); - next unless length $out or $part ne 'ID3v1' and $part ne 'Inf'; # Returns empty... + # Ignore 0-length answers from ID3v1 and Inf + next unless length $out or $part ne 'ID3v1' and $part ne 'Inf'; # Return empty... return [$out, $part] if $from; return $out; } @@ -382,7 +426,12 @@ filename) from which the value is taken. =item config - MP3::Tag->config("item", options, ...); + MP3::Tag->config(item => value1, value2...); # Set options globally + $mp3->config(item => value1, value2...); # Set object options + +When object options are first time set or get, the global options are +propagated into object options. (So if global options are changed later, these +changes are not inherited.) Possible items are: @@ -418,9 +467,44 @@ Possible items are: by the corresponding methods (e.g., comment()). Options can be "ID3v1", "ID3v2", "Inf", "CDDB_File", "filename". The order in which they are given to config also sets the order how they are - used by comment(). If an option is not present, then autoinfo option + used by comment(). If an option is not present, then C<autoinfo> option will be used instead. +* extension + + regular expression to match the file extension (including the dot). The + default is to match 1..4 letter extensions which are not numbers. + +* parse_data + + the data used by L<MP3::Tag::ParseData> handler; each option is an array + reference of the form C<[$flag, $string, $pattern1, ...]>. All the options + are processed in the following way: patterns are matched against $string + until one of them succeeds; the information obtained from later options takes + precedence over the information obtained from earlier ones. + +* parse_split + + The regular expression to split the data when parsing with C<n> or C<l> flags. + +* parse_filename_ignore_case + + If true (default), calling parse() and parse_rex() with match-filename + escapes (such as C<%=D>) matches case-insensitively. + +* parse_filename_merge_dots + + If true (default), calling parse() and parse_rex() with match-filename + escapes (such as C<%=D>) does not distinguish a dot and many consequent + dots. + +* parse_join + + string to put between multiple occurences of a tag in a parse pattern; + defaults to C<'; '>. E.g., parsing C<'1988-1992, Homer (LP)'> with pattern + C<'%c, %a (%c)'> results in comment set to C<'1988-1992; LP'> with the + default value of C<parse_join>. + * v2title Configure the elements of ID3v2-tag which are used by ID3v2::title(). @@ -435,19 +519,61 @@ Possible items are: =cut -sub config() { +sub config { my ($self, $item, @options) = @_; - $item = lc $item; - - if ($item !~ /^(autoinfo|title|artist|album|year|comment|track|genre|v2title|cddb_files)$/) { + my $config = ref $self ? ($self->{config} ||= {%config}) : \%config; + + if ($item =~ /^(force)$/) { + return $config->{$item} = {@options}; + } elsif ($item !~ /^(autoinfo|title|artist|album|year|comment|track|genre|v2title|cddb_files|force_interpolate|parse_data|parse_split|parse_join|parse_filename_ignore_case|parse_filename_merge_dots|extension)$/) { warn "MP3::Tag::config(): Unknown option '$item' found\n"; return; } - - $config{$item}=\@options; + + $config->{$item} = \@options; +} + +=item get_config + + $opt = $mp3->get_config("item"); + +When object options are first time set or get, the global options are +propagated into object options. (So if global options are changed later, these +changes are not inherited.) + +=cut + +sub get_config ($$) { + my ($self, $item) = @_; + ($self->{config} ||= {%config})->{lc $item}; } +=item get_user + + $data = $mp3->get_user($n); # n-th piece of user scratch space + +Queries an entry in a scratch array ($n=3 corresponds to C<%{u3}>). + +=item set_user + + $mp3->set_user($n, $data); # n-th piece of user scratch space + +Sets an entry in a scratch array ($n=3 corresponds to C<%{u3}>). + +=cut + +sub get_user ($$) { + my ($self, $item) = @_; + return unless $self->{userdata} and defined $self->{userdata}[$item]; + $self->{userdata}[$item]; +} + +sub set_user ($$$) { + my ($self, $item, $val) = @_; + $self->{userdata} ||= []; + $self->{userdata}[$item] = $val; +} =item interpolate @@ -472,6 +598,14 @@ The one-letter ESCAPEs are replaced by g => genre c => comment n => track + f => filename without the directory path + F => filename with the directory path + D => the directory path of the filename + E => file extension + e => file extension without the leading dot + A => absolute filename without extension + B => filename without the directory part and extension + N => filename as originally given without extension Additionally, ESCAPE can be a string (with all backslashes and curlies escaped) enclosed in curly braces C<{}>. The interpretation is the following: @@ -480,6 +614,20 @@ enclosed in curly braces C<{}>. The int =item * +C<d>I<NUMBER> is replaced by I<NUMBER>-th component of the directory name (with +0 corresponding to the last component). + +=item * + +C<U>I<NUMBER> is replaced by I<NUMBER>-th component of the user scratch +array. + +=item * + +C<D>I<NUMBER> is replaced by the directory name with NUMBER components stripped. + +=item * + Names of ID3v2 frames are replaced by their text values (empty for missing frames). @@ -488,12 +636,17 @@ frames). If string starts with C<FNAME:>: if frame FNAME does not exists, the escape is ignored; otherwise the rest of the string is reinterpreted (after stripping backslashes from backslashes and curlies). -other =item * String starting with C<!FNAME:> are treated similarly with inverted test. +=item * + +String starting with I<LETTER>C<:> or C<!>I<LETTER>C<:> are treated similarly +to ID3v2 conditionals, but the condition is that the corresponding escape +expands to non-empty string. + =over The default for the fill character is SPACE. Fill character should preceed @@ -505,11 +658,11 @@ will result in Title: TITLE///////; TIT3 is Op. 16 -If title is C<TITLE>, and TIT3 is C<Op. 16>, and +if title is C<TITLE>, and TIT3 is C<Op. 16>, and Title: TITLE///////. No TIT3 is present -If title is C<TITLE>, but TIT3 is not present. +if title is C<TITLE>, but TIT3 is not present. =cut @@ -520,7 +673,13 @@ my %trans = ( 't' => 'title', 'g' => 'genre', 'c' => 'comment', 'n' => 'track', + 'E' => 'filename_extension', + 'e' => 'filename_extension_nodot', + 'A' => 'abs_filename_noextension', + 'B' => 'filename_nodir_noextension', + 'N' => 'filename_noextension', 'f' => 'filename_nodir', + 'D' => 'dirname', 'F' => 'abs_filename' ); sub interpolate { @@ -529,12 +688,26 @@ sub interpolate { my $res = ""; my $ids; - while ($pattern =~ s/^([^%]+)|^%(?:(?:\((.)\)|([^-.\d]))?(-)?(\d+))?(?:\.(\d+))?([talygcnfF{%])//s) { + while ($pattern =~ s/^([^%]+)|^%(?:(?:\((.)\)|([^-.1-9]))?(-)?(\d+))?(?:\.(\d+))?([talygcnfFeEABDN{%])//s) { $res .= $1, next if defined $1; my ($fill, $left, $minwidth, $maxwidth, $what) = ((defined $2 ? $2 : $3), $4, $5, $6, $7); my $str; - if ($what eq '{') { # id3v2 stuff + if ($what eq '{' and $pattern =~ s/^([dD])(\d+)}//) { # Directory + if ($1 eq 'd') { + $str = $self->dir_component($2); + } else { + $str = $self->dirname($2); + } + } elsif ($what eq '{' and $pattern =~ s/^U(\d+)}//) { # User data + $str = $self->get_user($1); + } elsif ($what eq '{' and $pattern =~ s/^(!)?([talygcnfFeEABD]):((?:[^\\{}]|\\[\\{}])*)}//) { + my $neg = $1; + my $have = length($self->interpolate("%$2")); + next unless $1 ? !$have : $have; + ($str = $3) =~ s/\\([\\{}])/$1/g; + $str = $self->interpolate($str); + } elsif ($what eq '{') { # id3v2 stuff die "No ID3v2 present" unless $self->{ID3v2}; $pattern =~ s/^((?:[^\\{}]|\\[\\{}])*)}// or die "Mismatched {} in pattern `$pattern'"; $what = $1; @@ -549,7 +722,7 @@ sub interpolate { } else { die "unknown escape `$what'"; } - } elsif ($what eq %') { + } elsif ($what eq '%') { $str = '%'; } else { my $meth = $trans{$what}; @@ -567,20 +740,212 @@ sub interpolate { } $res .= $str; } - die "Can't parse `$pattern'" if length $pattern; + die "Can't parse `$pattern' during interpolation" if length $pattern; return $res; } +=item parse_rex($pattern, $string) + +Parse $string according to the regular expression $pattern with C<%>-escapes +C<%%, %a, %t, %l, %y, %g, %c, %n, %e, %E>. The meaning of escapes is the same +as for L<interpolate>. Also supported are escapes C<%=a, %=t, %=l, %=y, %=g, %=c, +%=n, %=e, %=E, %=A, %=B, %=D, %=f, %=F, %=N, %={WHATEVER}>; they match substrings which are +I<actual> values of +artist/title/etc (C<%=n> also matches leading 0s; actual file-name matches +ignore the difference between C</> and C<\>, between one and multiple +consequent dots (if configuration variable C<parse_filename_merge_dots> is true (default)) +and are case-insensitive if configuration variable C<parse_filename_ignore_case> +is true (default); +moreover, <%n>, <%y>, <%=n>, <%=y> will not match if the string-to-match +is adjacent to a digit). Returns false on failure, a hash reference with +parsed fields otherwise; the escape C<%{UE<lt>numberE<gt>}> matches any string, +and corresponds to the hash key C<UE<lt>numberU<gt>>. + + $res = $mp3->parse_rex(qr<^%a - %t\.\w{1,4}$>, $mp3->filename_nodir) or die; + $author = $res->{author}; + +2-digit numbers are allowed for the track number (the leading 0 is stripped); +4-digit years in the range 1000..2999 are allowed for year. + +Currently the regular expressions with capturing parens are not supported. + +=item parse_rex_prepare($pattern) + +Returns a data structure which later can be used by parse_rex_match(). +These two are equivalent: + + $mp3->parse_rex($pattern, $data); + $mp3->parse_rex_match($mp3->parse_rex_prepare($pattern), $data); + +This call constitutes the "slow part" of the parse_rex() call; it makes sense to +factor out this step if the parse_rex() with the same $pattern is called +against multiple $data. + +=item parse_rex_match($prepared, $data) + +Matches $data against a data structure returned by parse_rex_prepare(). +These two are equivalent: + + $mp3->parse_rex($pattern, $data); + $mp3->parse_rex_match($mp3->parse_rex_prepare($pattern), $data); + +=cut + +sub _rex_protect_filename { + my ($self, $filename, $what) = (shift, quotemeta shift, shift); + $filename =~ s,\\[\\/],[\\\\/],g; # \ and / are interchangeable + backslashitis + if ($self->get_config('parse_filename_merge_dots')->[0]) { + # HPFS doesn't distinguish x..y and x.y + $filename =~ s(\\\.+)(\\.+)g; + $filename =~ s($)(\\.*) if $what =~ /[ABN]/; + } + my $case = $self->get_config('parse_filename_ignore_case')->[0]; + return $filename unless $case; + return "(?i:$filename)"; +} + +sub _parse_rex_microinterpolate { # $self->idem($code, $groups, $ecount) + my ($self, $code) = (shift, shift); + return '%' if $code eq '%'; + $_[0] .= $code, return '((?<!\d)\d{1,2}(?!\d))' if $code eq 'n'; + $_[0] .= $code, return '((?<!\d)[12]\d{3}(?!\d))' if $code eq 'y'; + $_[0] .= $code, return '(.*)' if $code =~ /^[talgc]$/; + $_[1]++, return $self->_rex_protect_filename($self->interpolate("%$1"), $1) + if $code =~ /^=([ABDfFN]|{d\d+})$/; + $_[1]++, return quotemeta($self->interpolate("%$1")) + if $code =~ /^=([talgceE]|{.*})$/; + $_[1]++, return '(?<!\d)0*' . quotemeta($self->track) . '(?!\d)' + if $code eq '=n'; + $_[1]++, return '(?<!\d)' . quotemeta($self->year) . '(?!\d)' + if $code eq '=y'; + $_[0] .= $1, return '(.*)' if $code =~ /^{(U\d+)}$/; + # What remains is extension + my $e = $self->get_config('extension')->[0]; + $_[0] .= $code, return "($e)" if $code eq 'E'; + $_[0] .= $code, return "(?<=(?=(?:$e)\$)\\.)(.*)" if $code eq 'e'; + die "unknown escape `%$code'"; +} + +sub parse_rex_prepare { + my ($self, $pattern) = @_; + my ($codes, $exact) = ('', 0); + my $o = $pattern; + $pattern =~ s<%(={(?:[^\\{}]|\\[\\{}])*}|{U\d+}|=?.)> + ( $self->_parse_rex_microinterpolate($1, $codes, $exact) )seg; + my @tags = map { $_ =~ /U\d+/ ? $_ : $trans{$_} } ($codes =~ /(U\d+|.)/g); + return [$o, $pattern, \@tags, $exact]; +} + +sub parse_rex_match { # pattern = [Original, Interpolated, Fields, NumExact] + my ($self, $pattern, $data) = @_; + return unless @{$pattern->[2]} or $pattern->[3]; + my @vals = ($data =~ /$pattern->[1]/s) or return; + my $cv = @vals; + die "Unsupported regular expression `$pattern->[0]' (catching parens? Got $cv vals)" + unless @vals == @{$pattern->[2]}; + my ($c, %h) = 0; + for my $k ( @{$pattern->[2]} ) { + $h{$k} ||= []; + push @{ $h{$k} }, $vals[$c++]; # Support multiple occurences + } + my $j = $self->get_config('parse_join')->[0]; + for $c (keys %h) { + $h{$c} = join $j, grep length, @{ $h{$c} }; + } + $h{track} =~ s/^0// if exists $h{track}; + return \%h; +} + +sub parse_rex { + my ($self, $pattern, $data) = @_; + $self->parse_rex_match($self->parse_rex_prepare($pattern), $data); +} + +=item parse($pattern, $string) + +Parse $string according to the string $pattern with C<%>-escapes C<%%, %a, %t, +%l, %y, %g, %c, %n, %e, %E>. The meaning of escapes is the same as for L<interpolate>. +Returns false on failure, a hash reference with parsed fields otherwise. + + $res = $mp3->parse("%a - %t.mp3", $mp3->filename_nodir) or die; + $author = $res->{author}; + +2-digit numbers are allowed for the track number; 4-digit years in the range +1000..2999 are allowed for year. + +=item parse_prepare($pattern) + +Returns a data structure which later can be used by parse_rex_match(). +This is a counterpart of parse_rex_prepare() used with non-regular-expression +patterns. These two are equivalent: + + $mp3->parse($pattern, $data); + $mp3->parse_rex_match($mp3->parse_prepare($pattern), $data); + +This call constitutes the "slow part" of the parse() call; it makes sense to +factor out this step if the parse() with the same $pattern is called +against multiple $data. + +=cut + +#my %unquote = ('\\%' => '%', '\\%\\=' => '%='); +sub __unquote ($) { (my $k = shift) =~ s/\\(\W)/$1/g; $k } + +sub parse_prepare { + my ($self, $pattern) = @_; + $pattern = "^\Q$pattern\E\$"; + # unquote %. and %=. and %={WHATEVER} and %{WHATEVER} + $pattern =~ s<(\\%(?:\\=)?(\w|\\{(?:\w|\\[^\w\\{}]|\\\\\\[\\{}])*\\}|\\\W))> + ( __unquote($1) )ge; + # $pattern =~ s/(\\%(?:\\=)?)(\w|\\(\W))/$unquote{$1}$+/g; + return $self->parse_rex_prepare($pattern); +} + +sub parse { + my ($self, $pattern, $data) = @_; + $self->parse_rex_match($self->parse_prepare($pattern), $data); +} + =item filename() -=item abs_filename(), +=item abs_filename() + +=item filename_nodir() + +=item filename_noextension() + +=item filename_nodir_noextension() + +=item abs_filename_noextension() + +=item dirname([$strip_levels]) + +=item filename_extension() + +=item filename_extension_nodot() + +=item dir_component([$level]) $filename = $mp3->filename(); $abs_filename = $mp3->abs_filename(); $filename_nodir = $mp3->filename_nodir(); - -Return the name of the audio file (either as given to the new() method, or -absolute, or directory-less). + $abs_dirname = $mp3->dirname(); + $abs_dirname = $mp3->dirname(0); + $abs_parentdir = $mp3->dirname(1); + $last_dir_component = $mp3->dir_component(0); + +Return the name of the audio file: either as given to the new() method, or +absolute, or directory-less, or originally given without extension, or +directory-less without extension, or +absolute without extension, or the directory part of the fullname only, or +filename extension (with dot included, or not). + +The extension is calculated using the config() value C<extension>. + +The dirname() method takes an optional argument: the number of directory +components to strip; the C<dir_component($level)> method returns one +component of the directory (to get the last use 0 as $level; this is the +default if no $level is specified). =cut @@ -588,14 +953,65 @@ sub filename { shift->{ofilename} } +sub filename_noextension { + my $self = shift; + my $f = $self->{ofilename}; + my $ext_re = $self->get_config('extension')->[0]; + $f =~ s/$ext_re//; + return $f; +} + sub abs_filename { shift->{abs_filename} } sub filename_nodir { require File::Basename; - my ($out) = File::Basename::fileparse(shift->filename, ""); - $out + return scalar File::Basename::fileparse(shift->filename, ""); +} + +sub dirname { + require File::Basename; + my ($self, $l) = (shift, shift); + my $p = $l ? $self->dirname($l - 1) : $self->abs_filename; + return File::Basename::dirname($p); +} + +sub dir_component { + require File::Basename; + my ($self, $l) = (shift, shift); + return scalar File::Basename::fileparse($self->dirname($l), ""); +} + +sub filename_extension { + my $self = shift; + my $f = $self->filename_nodir; + my $ext_re = $self->get_config('extension')->[0]; + $f =~ /($ext_re)/ or return ''; + return $1; +} + +sub filename_nodir_noextension { + my $self = shift; + my $f = $self->filename_nodir; + my $ext_re = $self->get_config('extension')->[0]; + $f =~ s/$ext_re//; + return $f; +} + +sub abs_filename_noextension { + my $self = shift; + my $f = $self->abs_filename; + my $ext_re = $self->get_config('extension')->[0]; + $f =~ s/$ext_re//; + return $f; +} + +sub filename_extension_nodot { + my $self = shift; + my $e = $self->filename_extension; + $e =~ s/^\.//; + return $e; } sub DESTROY { @@ -611,7 +1027,8 @@ sub DESTROY { =head1 SEE ALSO -L<MP3::Tag::ID3v1>, L<MP3::Tag::ID3v2>, L<MP3::Tag::File> +L<MP3::Tag::ID3v1>, L<MP3::Tag::ID3v2>, L<MP3::Tag::File>, +L<MP3::Tag::ParseData>, L<MP3::Tag::Inf>, L<MP3::Tag::CDDB_File>. =head1 COPYRIGHT diff -pru another/work/test.pl ./work/test.pl --- another/work/test.pl Wed Jan 21 12:49:00 2004 +++ ./work/test.pl Thu Mar 11 17:34:30 2004 @@ -1,3 +1,4 @@ +#!/usr/bin/perl -w # Before `make install' is performed this script should be runnable with # `make test'. After `make install' it should work as `perl test.pl' @@ -6,11 +7,11 @@ # Change 1..1 below to 1..last_test_to_print . # (It may become useful if the test is moved to ./t subdirectory.) -BEGIN { $| = 1; print "1..44\n"; } +BEGIN { $| = 1; print "1..69\n"; } END {print "MP3::Tag not loaded :(\n" unless $loaded;} use MP3::Tag; $loaded = 1; -$count = 1; +$count = 0; ok(1,"MP3::Tag initialized"); ######################### End of black magic. @@ -40,6 +41,8 @@ ok($mp3->genre eq 'Ska', "Reading ID3v1 #test - reading ID3v2 ok($v2 && $v2->get_frame("COMM")->{short} eq "Test!","Reading ID3v2"); +{local *F; open F, '>test2.mp3' or warn; print F 'empty'} + $mp3 = MP3::Tag->new("test2.mp3"); $mp3->new_tag("ID3v1"); $v1 = $mp3->{ID3v1}; @@ -58,6 +61,10 @@ ok($v2 && $v2->write_tag,"Writing ID3v2" ok($v2 && $v2->add_frame("TLAN","GER"),"Changing ID3v2"); ok($v2 && $v2->write_tag,"Writing ID3v2"); +$mp3=$v1=$v2=undef; # Close the file... + +ok(((stat("test2.mp3"))[7] % 4096) == 0," ID3v2 rounding size"); + $mp3 = MP3::Tag->new("test2.mp3"); $mp3->get_tags; $v1 = $mp3->{ID3v1}; @@ -96,13 +103,13 @@ $mp3->get_tags; $v1 = $mp3->{ID3v1}; $v2 = $mp3->{ID3v2}; -ok($v2 && $v2->comment() eq 'Testing...', "Checking any-language comment"); +ok($v2 && $v2->comment() eq 'Another test...', "Checking any-language comment"); ok($v2 && !defined $v2->_comment('GER'), "Checking no GER comment"); -ok($v2 && $v2->_comment('ENG') eq 'Testing...', "Checking ENG comment"); -ok($v2 && $mp3->comment() eq 'Testing...', "Checking ID3 comment"); +ok($v2 && $v2->_comment('ENG') eq 'Another test...', "Checking ENG comment"); +ok($v2 && $mp3->comment() eq 'Another test...', "Checking ID3 comment"); -my $s = $mp3->interpolate('Title: `%12.12t\'; %{TLAN} %{TLAN01: have %\{TLAN01\}} %{!TLAN02:, do not have TLAN02}'); -ok($s && $s eq "Title: ` New'; ENG have GER , do not have TLAN02", "Checking ID3 interpolation"); +my $s = $mp3->interpolate('Title: `%012.12t\'; %{TLAN} %{TLAN01: have %\{TLAN01\}} %{!TLAN02:, do not have TLAN02}'); +ok($s && $s eq "Title: `000000000New'; ENG have GER , do not have TLAN02", "Checking ID3 interpolation"); #back to original tag open (FH, ">test2.mp3") or warn; binmode FH; @@ -156,17 +163,78 @@ ok($inf && $mp3->autoinfo('from')->{comm ok($inf && $mp3->autoinfo('from')->{comment}[1] eq 'Inf', "Checking .inf comment source"); require Cwd; -my $i = $mp3->interpolate('file=%(_)-12f, File=%F, comment="%c"'); +require File::Basename; +my $i = $mp3->interpolate('file=%(_)-12f, File=%F, %%comment="%c", dir="%{d0}"'); my $ii = 'file=test2.mp3___, File=' . Cwd::abs_path('test2.mp3') - . ', comment="Chiribim conducts Some Choir; recorded in Mariann"'; + . ', %comment="Chiribim conducts Some Choir; recorded in Mariann"' + . ', dir="' . scalar(File::Basename::fileparse(File::Basename::dirname(Cwd::abs_path('test2.mp3')),"")) . '"'; #warn "$i\n$ii\n"; ok($inf && $i eq $ii, "Checking interpolation"); ok($mp3->filename_nodir eq "test2.mp3", "Checking filename method:"); +ok($mp3 && $mp3->interpolate("%A.%e") eq $mp3->interpolate("%F"), "interpolate %A"); + +# Check CDDB_File... +ok(MP3::Tag->config('cddb_files', qw(cddb.tmp1 cddb.tmp cddb.tmp2)), "Configuring list of cddb_files"); + +open NH, '>audio07.mp3' or warn; +close NH; +$mp3 = MP3::Tag->new("./audio07.mp3"); +ok($mp3 && $mp3->title eq 'Makrokosmos III - I. Nocturnal Sounds (The Awakening)', "Title via CDDB_File"); +ok($mp3 && $mp3->artist eq 'Crumb Piece', "Artist via CDDB_File"); +ok($mp3 && $mp3->album eq 'Ancient Voices', "Album via CDDB_File"); +ok($mp3 && $mp3->year eq '1234', "Year via CDDB_File"); +ok($mp3 && $mp3->comment eq 'comment7; Fake entry', "Comment via CDDB_File"); +ok($mp3 && $mp3->genre eq 'A special genre', "Genre via CDDB_File"); +ok($mp3 && $mp3->track eq '7', "Track no with CDDB_File"); + +open NH, '>audio_07.mp3' or warn; +close NH; +$mp3 = MP3::Tag->new("./audio_07.mp3"); +$mp3->config(parse_data => ['m', 'no comment', '%c']); +ok($mp3 && $mp3->title eq 'Makrokosmos III - I. Nocturnal Sounds (The Awakening)', "Title via CDDB_File with force"); +ok($mp3 && $mp3->comment eq 'no comment', "Forced comment"); + +$mp3 = MP3::Tag->new("./audio_07.mp3"); +$mp3->config(parse_data => ['im', '<%c>', '%t']); +ok($mp3 && $mp3->artist eq 'Crumb Piece', "Artist via CDDB_File with force/interpolate"); +ok($mp3 && $mp3->title eq '<comment7; Fake entry>', "Force/interpolated title"); + +$mp3 = MP3::Tag->new("./audio_07.mp3"); +$mp3->config(parse_data => ['im', '[%t]' => '%t'], ['im', '<%t>' => '%c']); +ok($mp3 && $mp3->comment eq '<[Makrokosmos III - I. Nocturnal Sounds (The Awakening)]>', "Force/interpolated recursive comment"); +ok($mp3 && $mp3->title eq '[Makrokosmos III - I. Nocturnal Sounds (The Awakening)]', "Force/interpolated recursive title"); + +$mp3 = MP3::Tag->new("audio_07.mp3"); +$mp3->config(parse_data => ['im', '%f', '%c_%n.mp3'], ['mz', '' => '%g']); +ok($mp3 && $mp3->comment eq 'audio', "comment via parse"); +ok($mp3 && $mp3->track eq '7', "track via parse"); +ok($mp3 && $mp3->comment eq 'audio', "comment via cached parse"); +ok($mp3 && $mp3->title eq 'Makrokosmos III - I. Nocturnal Sounds (The Awakening)', "title with parse"); + +$s = $mp3->interpolate("%03n_%{!g: Have only comment=<%c>}<%g>%c"); +ok($mp3 && $s eq '007_ Have only comment=<audio><>audio', "conditional interpolation"); + +$mp3 = MP3::Tag->new("./audio_07.mp3"); +$mp3->config(parse_data => ['iRm', 'my/dir/%f', '/%c/%c%E']); +ok($mp3 && $mp3->comment eq 'dir; audio_07', "multi-%c via parse/interpolate"); + +$mp3 = MP3::Tag->new("./audio_07.mp3"); +$mp3->config(parse_data => ['iRm', 'my/dir/%f', '/%c/%c%=E']); +ok($mp3 && $mp3->comment eq 'dir; audio_07', "multi-%c and %=E via parse/interpolate"); + +$mp3 = MP3::Tag->new("./audio_07.mp3"); +$mp3->config(parse_data => ['im', 'my/dir/%f', '%t/%c/%c.%e']); +$i = $mp3->comment; +#warn "<$i>\n"; +ok($mp3 && $i eq 'dir; audio_07', "multi-%c and %e via parse/interpolate"); + +my @failed; +@failed ? die "Tests @failed failed.\n" : print "All tests successful.\n"; sub ok { my ($result, $test) = @_; - printf ("Test %2d %s %s", $count++, $test, '.' x (30-length($test))); - print " not" unless $result; + printf ("Test %2d %s %s", ++$count, $test, '.' x (45-length($test))); + (push @failed, $count), print " not" unless $result; print " ok\n"; }