#!/usr/bin/perl use strict; use warnings; use AudioFile::Info; use CDDB::File; use Cwd; use File::Basename; use File::Copy; use File::Glob qw(:glob); use File::Path; use File::Temp qw(tempdir); use Getopt::Long; use IO::File; use MP3::Tag; use Pod::Usage; use String::Format; use XML::Simple; my $progname = basename($0); my @valid_formats = qw(mp3 ogg both); my %meta = (); my $at_once = 0; my $bitrate = 160; my $cbr = 1; my $device = $ENV{'RIPPLE_DEVICE'} || '1,0,0'; my $dirty = 0; my $format = 'mp3'; my $help = 0; my $interactive = 1; my $make_dirs = 0; my $no_encode = 0; my $no_rip = 0; my $no_rename = 0; my $no_tag = 0; my $no_scan = 0; my $not_really = 0; my $verbose = 0; my $output_file = '%A/%L/%N-%T.%x'; my $quality; # scale with lame or oggenc? my $tmpdir_tmpl = $ENV{'RIPPLE_TMPDIR'} || $ENV{'TMPDIR'} || $ENV{'TEMP'} || $ENV{'TMP'} || "."; $tmpdir_tmpl .= "/$progname.XXXXXX"; my $rescue = 0; my %options = ( 'at-once|a' => \$at_once, 'batch|b' => sub { $interactive = 0 }, 'bitrate|B=i' => \$bitrate, 'cbr|C' => \$cbr, 'device|d=s' => \$device, 'format|f=s' => \$format, 'help|h|?' => \$help, 'interactive|i' => \$interactive, 'make-dirs|p' => \$make_dirs, 'rescue|r' => \$rescue, 'no-clean|D' => \$dirty, 'no-encode|E' => \$no_encode, 'no-rename|N' => \$no_rename, 'no-rip|R' => \$no_rip, 'no-scan|S' => \$no_scan, 'no-tag|T' => \$no_tag, 'not-really|n' => \$not_really, 'output-file|o=s' => \$output_file, 'quality|q=i' => \$quality, 'vbr|V' => sub { $cbr = 0 }, 'verbose|v' => \$verbose, ); my $rc = GetOptions(%options); if (not $rc or $help) { pod2usage(-exitval => 1, -verbose => 0); } unless (grep { lc($_) eq $format } @valid_formats) { die "$progname: Error: format must be mp3 or ogg\n"; } my @tracklist = map { range_expand($_) } @ARGV; my $oldcwd = getcwd(); my $tmpdir = tempdir($tmpdir_tmpl); if ($rescue) { warn "$progname: rescue mode engaged, working in $oldcwd\n"; } elsif (not chdir($tmpdir)) { die "$progname: Fatal: can't change directory to $tmpdir: $!\n"; } my $tracks = get_meta(); @tracklist = (1 .. $tracks) unless @tracklist; @tracklist = grep { ($_ > 0) && ($_ <= $tracks) } @tracklist; if ($at_once) { unless ($no_rip) { rip($_) for @tracklist; } unless ($no_encode) { encode($_) for @tracklist; } unless ($no_tag) { tag($_) for @tracklist; } unless ($no_rename) { funky_name($_) for @tracklist; } } else { for my $i (@tracklist) { rip($i) unless $no_rip; encode($i) unless $no_encode; tag($i) unless $no_tag; funky_name($i) unless $no_rename; } } clean() unless ($dirty or $rescue); sub range_expand { my ($lo, $hi) = split(/-/, $_[0]); $hi ||= $lo; if ($lo < 1) { warn "$progname: Warning: invalid track number $lo, using 1 instead\n"; $lo = 1; } if ($hi > 99) { warn "$progname: Warning: invalid track number $hi, using 99 instead\n"; $hi = 99; } if ($lo > $hi) { die "$progname: Fatal: invalid range $lo-$hi\n"; } return ($lo .. $hi); } sub get_meta { unless ($no_scan) { my $r = maybe_system('cdda2wav', '-D', $device, '-J', '-L', $interactive ? '0' : '1'); if ($r) { die "$progname: Fatal: could not get CD meta\n"; } } my $tracks = get_num_tracks() || scalar(glob("audio*.inf")); for my $i (1..$tracks) { for my $j (qw(inf wav mp3 ogg)) { $meta{'tracks'}->[$i]->{$j} = sprintf("audio_%02d.%s", $i, $j); } # XXX abstraction my $info = MP3::Tag::Inf->new_with_parent($meta{'tracks'}->[$i]->{'inf'}); next unless $info; # may be data track $info->parse(); $meta{'tracks'}->[$i]->{'info'} = $info; } return $tracks; } sub get_num_tracks { return get_num_tracks_cddb() || get_num_tracks_xml() || count_inf_files(); } sub get_num_tracks_cddb { my $t; my $disc; eval { $disc = CDDB::File->new("audio.cddb"); $t = $disc->track_count(); $meta{'cddb'} = $disc; }; return $t; } sub get_num_tracks_xml { my $t; my $disc; eval { $disc = XMLin("audio.cdindex"); $t = $disc->{'NumTracks'}; $meta{'xml'} = $disc; }; return $t; } sub count_inf_files { my @t = glob("audio_*.inf"); my $t = @t; return $t; } sub clean { chdir $oldcwd; rmtree($tmpdir) unless $not_really; } sub rip { my $track = shift; my $out = sprintf("audio_%02d.wav", $track); $meta{'tracks'}->[$track]->{'wav'} = $out; my $r = maybe_system('cdda2wav', '-D', $device, '-g', '-B', '-t', "$track+$track"); if ($r) { warn "$progname: Warning: error ripping track $track\n"; return; } my $o = sprintf("audio_%02d.wav", $track); rename("audio.wav", $o); } sub encode { my $track = shift; print "encode $track\n"; if ($format eq 'mp3' or $format eq 'both') { encode_mp3($track); } if ($format eq 'ogg' or $format eq 'both') { encode_ogg($track); } } sub tag { my $track = shift; print "tag $track\n"; if ($format eq 'mp3' or $format eq 'both') { tag_mp3($track); } if ($format eq 'ogg' or $format eq 'both') { tag_ogg($track); } } sub maybe_system { local($,) = " "; print @_, "\n"; return 0 if $not_really; system(@_); } # lame VBR quality: 0-9, 0 highest, 4 default, we choose 3 =~ 160kbps. sub encode_mp3 { my $track = shift; my $infile = $meta{'tracks'}->[$track]->{'wav'}; my $outfile = $infile; $outfile =~ s/wav$/mp3/; $meta{'tracks'}->[$track]->{'mp3'} = $outfile; my @bitrates; if ($cbr) { push @bitrates, '--cbr', '-b', $bitrate; } else { $quality ||= 3; push @bitrates, '--vbr', '--vbr-new', '-V', $quality; } my $r = maybe_system('lame', @bitrates, $infile, $outfile); } sub encode_ogg { my $track = shift; my $infile = $meta{'tracks'}->[$track]->{'wav'}; my $outfile = $infile; $outfile =~ s/wav$/ogg/; $meta{'tracks'}->[$track]->{'ogg'} = $outfile; my @bitrates; if ($cbr) { push @bitrates, '-b', $bitrate; } else { $quality ||= 5; push @bitrates, '-q', $quality; } my $r = maybe_system('oggenc', @bitrates, '-o', $outfile, $infile); } sub tag_mp3 { my $track = shift; my $mp3file = $meta{'tracks'}->[$track]->{'mp3'}; my $info = $meta{'tracks'}->[$track]->{'info'}; return unless $info; my $mp3 = MP3::Tag->new($mp3file); $mp3->get_tags(); my $v1tag = $mp3->new_tag('ID3v1'); my $v2tag = $mp3->new_tag('ID3v2'); my $genre = $mp3->genre(); for my $t ($v1tag, $v2tag) { $t->artist($info->{'Performer'} || $info->artist()); $t->album($info->album()); $t->genre($genre); $t->comment($info->comment()); $t->year($info->year()); $t->title($info->title()); $t->track($info->track()); $t->write_tag() unless $not_really; } $meta{'tracks'}->[$track]->{'ID3v1'} = $v1tag; $meta{'tracks'}->[$track]->{'ID3v2'} = $v2tag; } sub tag_ogg { my $track = shift; my $oggfile = $meta{'tracks'}->[$track]->{'ogg'}; my $info = $meta{'tracks'}->[$track]->{'info'}; return unless $info; my $ogg = AudioFile::Info->new($oggfile); my ($title, $artist, $album, $year, $comment) = $info->parse(); $ogg->title($info->title()); $ogg->artist($info->{'Performer'} || $artist); $ogg->album($info->album()); $ogg->year($info->year()); $ogg->{'obj'}->write_vorbis() unless $not_really; # XXX abstraction $meta{'tracks'}->[$track]->{'ogghdr'} = $ogg; return; } sub funky_name { my $track = shift; if (-f $meta{'tracks'}->[$track]->{'mp3'}) { funky_name_help($track, 'mp3'); } if (-f $meta{'tracks'}->[$track]->{'ogg'}) { funky_name_help($track, 'ogg'); } } sub expando_a { $meta{'tracks'}->[$_[0]]->{'info'}->{'info'}->{'Albumperformer'}; } sub expando_A { unix_friendly(expando_a(@_)); } sub expando_c { $meta{'tracks'}->[$_[0]]->{'info'}->comment(); } sub expando_C { unix_friendly(expando_c(@_)); } sub expando_l { $meta{'tracks'}->[$_[0]]->{'info'}->album(); } sub expando_L { unix_friendly(expando_l(@_)); } sub expando_s { $meta{'tracks'}->[$_[0]]->{'info'}->{'info'}->{'Performer'}; } sub expando_S { unix_friendly(expando_s(@_)); } sub expando_t { $meta{'tracks'}->[$_[0]]->{'info'}->title(); } sub expando_T { unix_friendly(expando_t(@_)); } sub expando_y { $meta{'tracks'}->[$_[0]]->{'info'}->year(); } sub funky_name_help { my $track = shift; my $fmt = shift; my %stencil = ( 'a' => sub { expando_a($track) }, 'A' => sub { expando_A($track) }, 'c' => sub { expando_c($track) }, 'C' => sub { expando_C($track) }, 'l' => sub { expando_l($track) }, 'L' => sub { expando_L($track) }, 'n' => $track, 'N' => sprintf("%02d", $track), 's' => sub { expando_s($track) }, 'S' => sub { expando_S($track) }, 't' => sub { expando_t($track) }, 'T' => sub { expando_T($track) }, 'x' => $fmt, 'X' => uc($fmt), 'y' => sub { expando_y($track) }, ); my $newname = stringf($output_file, %stencil); my $oldname = $meta{'tracks'}->[$track]->{$fmt}; warn "rename $oldname => $newname\n"; if ($make_dirs) { my ($n, $p) = fileparse($newname); mkpath($p); } unless ($not_really) { move($oldname, $newname) || warn "$progname: can't rename: $!\n"; } }; sub unix_friendly { my $x = shift; $x =~ s/\s+/_/g; $x =~ s/\&/and/g; $x =~ s/[\`\!\#\$\*\(\)\[\]\'\"\;\:\,\<\>\?\|\\\/]+//g; $x =~ s/~+/_/g; return $x; } __END__ =head1 NAME ripple - Rip and encode a music CD to MP3 and/or OGG =head1 SYNOPSIS B [B<-abchinpvCDENRSTV?>] [B<-B> I] [B<-d> I] [B<-f> {mp3|ogg|both}] [B<-o> I] [B<-q> I] [B<--at-once>] [B<--batch>] [B<--bitrate>=I] [B<--cbr>] [B<--device>=I] [B<--format>={mp3|ogg|both}] [B<--help>] [B<--interactive>] [B<--make-dirs>] [B<--no-clean>] [B<--no-encode>] [B<--no-rename>] [B<--no-rip>] [B<--no-scan>] [B<--no-tag>] [B<--not-really>] [B<--output-file>=I] [B<--quality>=I] [B<--vbr>] [B<--verbose>] [I ...] =head1 DESCRIPTION B is a well-featured program for expanding your digital music collection. It requires that B be installed, and at least one of B or B be installed. It also requires some Perl modules to be installed: C, C, L, and L. B will happily take you nowhere without these programs and modules. The program operates in several phases: scan, rip, encode, tag, rename, and clean. Any of these phases can be omitted, though it may not make sense to omit some arbitrarily. The scan phase gathers as much meta-information about the CD in the drive as possible. The rip phase copies data from the CD to local disk, in WAV format. The encode phase turns WAV files into MP3 and/or OGG. The tag phase adds meta-information to the encoded music files. The rename phase helps you organize your music collection. The clean phase gets rid of extra cruft you don't need. =head2 Options Many, many command-line switches can help you customize B's behavior. =over 4 =item B<-a> =item B<--at-once> Perform each phase for all tracks together, rather than cycle through phases one track at a time. =item B<-B> =item B<--batch> In the scan phase, do not ask for user input. Incompatible with B<-i> and B<--interactive>. =item B<-b> I =item B<--bitrate>=I Specify a bit rate for encoded files. For MP3 files, this enforces constant bit-rate; for OGG, this is merely a guideline. The default is 160. =item B<-C> =item B<--no-clean> Skip the clean phase. =item B<-c> =item B<--cbr> Ask for a constant bit-rate encoding of MP3 files. Meaningless for OGG. Incompatible with I<-V> and I<--vbr>. =item B<-d> I =item B<--device>=I Specify which device B should use. The default is B<1,0,0>. =item B<-E> =item B<--no-encode> Skip the encode phase. =item B<-f> {mp3|ogg|both} =item B<--format>={mp3|ogg|both} Specify how to encode music files. The defaut is to encode to MP3 only. =item B<-h> =item B<-?> =item B<--help> Ask for help. =item B<-i> =item B<--interactive> In the scan phase, ask for user input if necessary. Incompatible with B<-b> and B<--batch>. =item B<-N> =item B<--no-rename> Skip the rename phase. =item B<-n> =item B<--not-really> Avoid modifying local disk contents. May not work exactly how you expect. =item B<-o> I =item B<--output-file>=I Specify how files are supposed to be renamed. See L<"Renaming Files"> below. The default is C<%A/%L/%N-%T.%x>; =item B<-p> =item B<--make-dirs> When renaming files, make directories if necessary to accomodate the new names. =item B<-q> I =item B<--quality>=I Specify an encoding quality. For variable bitrate MP3, the default is 3 (0 highest, 9 lowest). For OGG, the default is 5 (0 lowest, 10 highest). For constant bitrate MP3, this is ignored. =item B<-R> =item B<--no-rip> Skip the rip phase. =item B<-S> =item B<--no-scan> Skip the scan phase. =item B<-T> =item B<--no-tag> Skip the tag phase. =item B<-V> =item B<--vbr> Use variable bitrate encoding for MP3. Ignored for OGG. =item B<-v> =item B<--verbose> Be more chatty about what is happening. Currently unimplemented. =back =head2 Renaming Files Files are renamed from a template with a series of %-escapes. The various escapes are: %a Album artist %A Album artist, CLI friendly %c Comment (ignored for OGG) %C Comment, CLI friendly (ignored for OGG) %l Album name %L Album name, CLI friendly %n Track number (1-99) %N Track number, two digits (01-99) %s Song artist %S Song artist, CLI friendly %t Title %T Title, CLI friendly %x Encoding format, lower case (mp3 or ogg) %X Encoding format, upper case (MP3 or OGG) %y Year "CLI Friendly" means all whitespace is converted to underscores, and most funny symbols and punctuation are stripped out completely. =head2 Environment Variables C specifies which CD device to use. The default is C<1,0,0> which may not be reasonable on your system. C specifies a temporary directory. If it is not defined, then C, C, and C are checked (in that order). The default is to use the current directory. =head1 AUTHOR Tony Monroe Etmonroe plus perl at nog dot netE =head1 BUGS Probably a lot. =head1 SEE ALSO Programs: L, L, L. Perl modules: L, L, L, L