#!/usr/bin/perl # # -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- # # ripx - cd ripping with control # # If you spot any bugs or have any ideas for improvements, feel free to get in # contact with me through http://planzero.org/contact/ # # The code used in the cdinfo() function was derrived from the POE CD Detect # module; my thanks to Erick Calder for this nifty piece of work. # # New in this release: # -------------------- # - support for multiple cddb matches (finally!) # - automatic update checking # # To be added: # ------------ # - track title duplication checking # - cddb connection timeout handling # - id3 tag year validation # - rip timer # - suggestions? # # -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- # # Copyright (c) 2006 shaman - http://planzero.org # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- # $|=1; use strict; # whip me use warnings; # whip me harder use Fcntl; # sysopen() constants use IO::Socket; # socket stuff # Constants you may want to change use constant DEVICE => '/dev/cdrom'; use constant ENCODE_QUALITY => 'standard'; # http://lame.sourceforge.net/doc/html/presets.html use constant PLAYLIST => 'playlist.txt'; # Change to '' to skip playlist creation use constant ALBUM_DIRECTORY => 1; # Create a directory for each album? use constant FREEDB_SERVER => 'uk.freedb.org'; # Use freedb.freedb.org for a random freedb server, use constant FREEDB_PORT => 8880; # or check out freedb.org for a list of local mirrors use constant VERBOSITY => 0; # 0-2, useful for debugging or for the curious # Set this to '1' to get ripx to check planzero.org for updates automatically, or '0' not to do so use constant UPDATES => 1; # Check for updated versions of ripx? # You probably won't want to change too much below here.. use constant PROGRAM => 'ripx'; use constant VERSION => '0.10'; use constant MAX_READ => 8192; use constant BIG_ENDIAN => unpack("h*", pack("s", 1))=~/01/; # Global variables my $max_title_length=0; sub error { my ($errmsg, $fatal)=@_; print $fatal ? "\nFatal error: $errmsg\n" : "\nError: $errmsg\n"; if ($fatal) { print ".. exiting!\n\n"; exit($fatal); } } sub init { my $info; $info->{device}=DEVICE; chomp($info->{os}=qx(uname -s)); # *BSD - /usr/include/sys/cdio.h if ($info->{os}=~/BSD$/i) { $info->{tochdr}=0x40046304; # TOC header $info->{tocentry}=0xc0086305; # TOC entry # SunOS - /usr/include/sys/cdio.h } elsif ($info->{os} eq "SunOS") { $info->{tochdr}=0x49b; # TOC header $info->{tocentry}=0x49c; # TOC entry # Linux - /usr/include/linux/cdrom.h } else { error("Unrecognised OS! Using default values - this might not work!", 0) if ($info->{os} ne "Linux"); $info->{tochdr}=0x5305; # TOC header $info->{tocentry}=0x5306; # TOC entry } $info->{msf}=0x02; # Minute-second-frame return $info; } sub cdinfo { my ($i, $j)=(0, 0); my ($track_min, $track_sec, $track_frame); my ($tochdr, $tocentry)=('', ''); my ($discid, $toc); my @toc; my $info=shift; sysopen(STREAM, $info->{device}, O_RDONLY | O_NONBLOCK) || error("couldn't open CD device '$info->{device}'", 1); ioctl(STREAM, $info->{tochdr}, $tochdr) || error("couldn't read from CD device '$info->{device}'", 1); $tochdr=substr($tochdr, 2, 2) if ($info->{os}=~/BSD$/); my ($start, $end)=unpack("CC", $tochdr); my $size=$end - $start + 2; $toc=" " x ($size * 8); if ($info->{os}=~/BSD$/) { my $size_high=BIG_ENDIAN ? int($size / 256) : $size & 255; my $size_low=BIG_ENDIAN ? $size & 255 : int($size / 256); $tocentry=pack("CCCCP8l", $info->{msf}, 0, $size_high, $size_low, $toc); ioctl(STREAM, $info->{tocentry}, $tocentry) || error("couldn't read track information from CD device '$info->{device}'", 1); } for $i ($start .. $end, 0xaa) { if ($info->{os}=~/BSD$/) { ($track_min, $track_sec, $track_frame)=unpack("CCC", substr($toc, $j + 5, 3)); $j+=8; } else { $tocentry=pack("CCC", $i, 0, $info->{msf}); ioctl(STREAM, $info->{tocentry}, $tocentry) || error("couldn't read track information from CD device '$info->{device}'", 1); ($track_min, $track_sec, $track_frame)=unpack("CCC", substr($tocentry, 4, 4)); } push @toc, { min => $track_min, sec => $track_sec, start => int($track_frame + 75 * (60 * $track_min + $track_sec)) }; } close(STREAM); $discid=generate_discid(@toc); return ($discid, @toc); } sub generate_discid { my @toc=@_; my $toc_count=$#toc; my ($i, $t, $n, $x, $y)=(0, 0, 0, 0, 0); for ($i=0; $i<$toc_count; $i++) { $x=(($toc[$i]->{min} * 60) + $toc[$i]->{sec}); while ($x>0) { $y+=($x % 10); $x=int $x / 10; } $n+=$y; $y=0; } $t = (($toc[$toc_count]->{min} * 60) + $toc[$toc_count]->{sec}) - (($toc[0]->{min} * 60) + $toc[0]->{sec}); return (($n % 0xff) << 24 | $t << 8 | $toc_count); } sub cddb_send_query { my ($socket, $query)=@_; my ($data, $data_tmp, $terminator, $response_code, $response_message); send($socket, $query . "\n", 0); print " -> $query\n" if (VERBOSITY>0); if ($query=~/^cddb read/) { $terminator='^\.$'; } else { $terminator='.$'; } do { recv($socket, $data_tmp, MAX_READ, 0); # MSG_WAITALL ? $data_tmp=~s/\r//g; $data.=$data_tmp; } until ($data=~m/$terminator/m); chomp($data); if ($query=~/^cddb read/ && VERBOSITY==1) { $data=~m/^(.*?)$/m || error("cddb read first line for lower verbosity regexp failed", 0); print "<- $1\n" } elsif (VERBOSITY>0) { print "<- $data\n"; } $data=~m/^(\d+)\s(.+)$/s || error("response code regexp failed", 0); $response_code=$1; $response_message=$2; return ($response_code, $response_message); } sub cddb_lookup { my ($discid, @toc)=@_; my (@tracks, $data, $query, $i, $response_code, $response_message, $disc_title); my $toc_count=$#toc; # Add the track times to the tracks array for ($i=0; $i<$toc_count; $i++) { my $track_total_secs=($toc[$i+1]->{start} - $toc[$i]->{start}) / 75; $tracks[$i]->{hours}=($track_total_secs / (60 * 60)) % 24; $tracks[$i]->{mins}=($track_total_secs / 60) % 60; $tracks[$i]->{secs}=$track_total_secs % 60; } my $socket=new IO::Socket::INET ( PeerAddr => FREEDB_SERVER, PeerPort => FREEDB_PORT, Proto => 'tcp' ) || error("couldn't connect to freedb server '" . FREEDB_SERVER . ":" . FREEDB_PORT . "'", 1); recv($socket, $data, MAX_READ, 0); $data=~s/\n|\r//g; print "\n<- $data\n" if (VERBOSITY>0); # Say hello ($response_code, $response_message)=cddb_send_query($socket, "cddb hello nobody nohost " . PROGRAM . " v" . VERSION); if ($response_code==431) { error("connected, but couldn't handshake with cddb server - reading album information manually:", 0); return(-1, -1, -1, @tracks); } elsif ($response_code!=200) { error("the cddb server sent an unrecognised response code ($response_code). Continuing, but things might go weird", 0); } # Build and send cddb query $query="cddb query " . sprintf("%x", $discid) . " $toc_count"; for ($i=0; $i<$toc_count; $i++) { $query.=" $toc[$i]->{start}"; } $query.=" " . (($toc[$toc_count]->{min} * 60) + $toc[$toc_count]->{sec}); ($response_code, $response_message)=cddb_send_query($socket, $query); if ($response_code==211) { print "\nMultiple CD matches found. Match listing:\n\n"; my $i=0; my @matches; while ($response_message=~s/^(\w+) (\w+) (.+) \/ (.+)?$//m) { $matches[$i]->{genre}=$1; $matches[$i]->{discid}=$2; $matches[$i]->{artist}=$3; $matches[$i]->{album}=$4; printf("[%2d] %s / %s (%s)\n", $i, $matches[$i]->{artist}, $matches[$i]->{album}, $matches[$i]->{genre}); $i++; } print "\n"; my $id=-1; do { print "Please choose the correct album from the list above (0-" . ($i - 1) . ") [0] "; chomp($id=); $id=0 if (!$id); if (!($id=~m/^\d+$/ && $id>=0 && $id<$i)) { print "Invalid selection!\n"; $id=-1; } } while ($id<0); # Fake a response message to be parsed below :-) $response_message=$matches[$id]->{genre} . " " . $matches[$id]->{discid} . " " . $matches[$id]->{artist} . " / " . $matches[$id]->{album}; print "\nFake response: $response_message\n\n" if (VERBOSITY>1); } elsif ($response_code==202) { error("no cddb matches found for this album - reading album information manually:", 0); return(-1, -1, -1, @tracks); } elsif ($response_code==403) { error("cddb match found, but database entry is corrupt - reading album information manually:", 0); return(-1, -1, -1, @tracks); } elsif ($response_code!=200) { error("the cddb server sent an unrecognised response code ($response_code). Continuing, but things might go weird", 0); } # Extract genre, artist and album information if (!($response_message=~m/^(.+?)\s(.+?)\s(.*?)\s\/\s(.*?)\s*$/)) { error("query response regexp failed - reading album information manually:", 0); return(-1, -1, -1, @tracks); } my ($genre, $artist, $album)=($1, $3, $4); $discid=hex($2); # Send read request for full album information ($response_code, $response_message)=cddb_send_query($socket, "cddb read $genre " . sprintf("%x", $discid)); close($socket); if ($response_code==401) { error("cddb reports that the requested database entry does not exist but previously stated that it did. Eh?! Reading album information manually:", 0); return(-1, -1, -1, @tracks); } elsif ($response_code==402) { error("cddb server reports server error. Perhaps try later. Reading album information manually:", 0); return(-1, -1, -1, @tracks); } elsif ($response_code==403) { error("cddb match found, but database entry is corrupt - reading album information manually:", 0); return(-1, -1, -1, @tracks); } elsif ($response_code!=210) { error("the cddb server sent an unrecognised response code ($response_code). Continuing, but things might go weird", 0); } # Add the track titles to the tracks array for ($i=0; $i<$toc_count; $i++) { my $regexp="^TTITLE$i=(.*?)\\s*\$"; my $track_title=""; $track_title.=$1 while ($response_message=~s/$regexp//im); $tracks[$i]->{title}=$track_title; $max_title_length=length($track_title) if (length($track_title)>$max_title_length); } $disc_title.=$1 while ($response_message=~s/^DTITLE=(.*?)$//im); error("couldn't get disc title from cddb entry", 0) if (!$disc_title); $genre='other' if ($genre eq 'misc'); # Turn the misc genre into something that lame understands my ($cddb_artist, $cddb_album)=split(/\s+\/\s+/, $disc_title); return ($cddb_artist, $cddb_album, $genre, @tracks); } sub check_for_updates { # As curious as I am, there is no code in here to steal your identity, soul or porn collection ;-) # If you don't want this code to be executed, simply change the UPDATES constant above to 0! print "\nChecking for updates.. " if (VERBOSITY>1); my $socket=new IO::Socket::INET ( PeerAddr => 'planzero.org', PeerPort => 80, Proto => 'tcp' ); if (!$socket) { print "couldn't connect!\n\n" if (VERBOSITY>1); return -1; } print "connected.. " if (VERBOSITY>1); send($socket, "GET /code/projects/ripx/update.php?p=" . PROGRAM . "&v=" . VERSION . " HTTP/1.0\nHost: planzero.org\n\n", 0); my $data; recv($socket, $data, 1024, 0); my ($header, $body)=split(/\r\n\r\n/, $data, 2); $header=~m/^HTTP\/\d.\d (\d{3,})/; if ($1!='200') { print "bad http response code ($1)!\n\n" if (VERBOSITY>1); return -1; } if ($body=~m/^(-|\+)(\d{3,}) (.+?)$/ eq '') { print "error parsing http body!\n\n" if (VERBOSITY>1); return -1; } my ($sign, $code, $message)=($1, $2, $3); if ($sign eq '-') { print "error $code ($message)\n\n" if (VERBOSITY>1); return -1; } if ($code eq '008') { print "no updates found\n\n" if (VERBOSITY>1); return 0; } if ($code eq '016') { print "update found\n" if (VERBOSITY>1); print "\nNotice: A new version of " . PROGRAM . " is available!\n"; print " $message\n" if ($message); print "\n" if (VERBOSITY>1); } else { print "unrecognised response $code ($message)\n\n" if (VERBOSITY>1); return -1; } return 0; } sub read_user_value { my $key=shift; my $value=shift; my $padding=shift; if ($value=~m/-1/) { printf("%s: ", $key); } else { $padding=$padding - length($key) - length($value); printf("%s: %s %s New value: ", $key, $value, " " x $padding); } chomp(my $input=); return $input; } # Let the show begin.. print PROGRAM . " v" . VERSION . " - (c)oded 2006 shaman - http://planzero.org\n"; check_for_updates() if (UPDATES); my $cdparanoia_executable=qx(which cdparanoia) || error("the cdparanoia executable was not found in your current path. Please modify your path or install cdparanoia.", 1); my $lame_executable=qx(which lame) || error("the lame executable was not found in your current path. Please modify your path or install lame.", 1); chomp($cdparanoia_executable); chomp($lame_executable); my $padding=0; my ($discid, @toc)=cdinfo(init()); printf("Disc ID: %x Tracks: %d\n", $discid, $#toc) if (VERBOSITY>1); my ($artist, $album, $genre, @tracks)=cddb_lookup($discid, @toc); my $year=''; if ($artist=~m/-1/ || $album=~m/-1/) { print "\n"; $album=read_user_value("Album", -1, 0); $artist=read_user_value("Artist", -1, 0); $genre=read_user_value("Genre", -1, 0); $year=read_user_value("Year", -1, 0); $year=~s/\D//g; print "\n"; for (my $i=0; $i<$#toc; $i++) { $tracks[$i]->{title}=read_user_value("Track " . ($i + 1), -1, 0); if ($tracks[$i]->{title} eq '') { error("please enter a valid track name", 0); $i--; } else { $max_title_length=length($tracks[$i]->{title}) if (length($tracks[$i]->{title})>$max_title_length); } } } print "\nThis album is $album by $artist, the genre is $genre and the release year " . ($year ? (((localtime(time))[5]+1900)==$year ? "is this year" : "was $year") : "is unknown") . ". Track listing:\n\n"; #print "\nTrack listing:\n\n"; #print "-------------\n"; my $track_number=0; for my $track (@tracks) { $track_number++; $padding=" " x ($max_title_length-length($track->{title})); printf("%2d) %s %s (%.2d:%.2d)\n", $track_number, $track->{title}, $padding, $track->{mins}, $track->{secs}); } print "\n"; my ($action, $tmp); do { print "Do you wish to modify (a)lbum information, (t)racks or to (r)ip now? [r] "; chomp($action=lc(substr(, 0, 1))); if ($action eq 'a') { print "\nPlease enter new values at each prompt or leave the field blank to keep the original value:\n\n"; $padding=(length($album)>length($artist)) ? length($album) : length($artist); $padding+=6; # $padding + length of the longest prompt $album=read_user_value("Album", $album, $padding) || $album; $artist=read_user_value("Artist", $artist, $padding) || $artist; $genre=read_user_value("Genre", $genre, $padding) || $genre; $year=read_user_value("Year", $year, $padding) || $year; $year=~s/\D//g; print "\n"; } elsif ($action eq 't') { print "\nPlease enter new values at each prompt or leave blank to keep the original value:\n\n"; for (my $i=0; $i<$#toc; $i++) { $track_number=$i+1; $tracks[$i]->{title}=read_user_value("Track $track_number", $tracks[$i]->{title}, $max_title_length + 8) || $tracks[$i]->{title}; } print "\n"; } elsif ($action eq '') { $action="r"; } else { $action=""; } } until ($action eq 'r'); print "\n"; # Here's the fun bit (the rip stage, dummy) if (ALBUM_DIRECTORY) { my $album_directory="$artist - $album"; $album_directory=~s/\//-/g; mkdir($album_directory) || error("couldn't create album directory!", 0) if (! -d $album_directory); chdir($album_directory) || error("couldn't change into album directory!", 1); } unlink(PLAYLIST) if (PLAYLIST); my $total_file_size=0; $track_number=0; for my $track (@tracks) { $track_number++; $track->{title}=~s/\//-/g; my $output_filename="$artist - $track->{title}.ripx"; my $final_filename="$artist - $track->{title}.mp3"; my $cmd_read="$cdparanoia_executable --quiet --force-cdrom-device " . DEVICE . " --output-wav --abort-on-skip $track_number -"; my $cmd_encode="$lame_executable --preset " . ENCODE_QUALITY . " --tn $track_number" . ($track->{title} ? " --tt \"$track->{title}\"" : "") . ($artist ? " --ta \"$artist\"" : "") . ($album ? " --tl \"$album\"" : "") . ($genre ? " --tg \"$genre\"" : "") . ($year ? " --ty $year" : "") . " /dev/stdin \"$output_filename\" 2>&1"; print "Read command: $cmd_read\n" if (VERBOSITY>1); print "Encode command: $cmd_encode\n" if (VERBOSITY>1); open(CMD, "$cmd_read | $cmd_encode |") || error("problem executing cdparanoia or lame!", 1); my ($data, $data2)=('', ''); while (read(CMD, $data, 128)) { chomp($data2.=$data); if ($data2=~m/\s*?(\d+\/\d+)\s*?\(\s*?(\d{1,3}%)\)\|.+?\|.+?\|.+?\|\s*?(\d+:\d+)\s*?/) { printf("[%2d/%2d] Encoded %s of '%s' ETA: %s%s\r", $track_number, $#toc, $2, $final_filename, $3, " " x 4); $data2=""; } elsif ($data2=~m/lame --longhelp/) { error("problem reported by lame whilst encoding track $track_number", 0); $data2=~/^(.*?)\nLAME.*/; error("lame sayeth ** $1 **", 1); } elsif ($data2=~m/Unknown genre:/) { print "\n$data2"; error("lame says that $genre is an unknown genre, use lame --genre-list for a full list of valid genres", 1); exit(0); } } error("problem ripping track $track_number - no file written by encoder!\nRip command: $cmd_read | $cmd_encode", 1) if (! -e $output_filename); rename($output_filename, $final_filename) || error("couldn't rename '$output_filename' to '$final_filename'", 1); my $file_size_bytes=-s $final_filename || 0; my $file_size_mib=(($file_size_bytes / 1024) / 1024); $total_file_size+=$file_size_bytes; error("problem ripping track $track_number - file size is $file_size_bytes bytes!", 1) if ($file_size_bytes==0); printf("[%2d/%2d] Encoding of '%s' complete, file size is %1.3f MiB\n", $track_number, $#toc, $final_filename, $file_size_mib); # Add the mp3 to the playlist file if (PLAYLIST) { if (!open(PLIST, ">>" . PLAYLIST)) { error("couldn't open playlist file for writing", 0); } else { print PLIST "$final_filename\n"; close(PLIST); } } } print "\n"; printf("Total album size: %1.2f MiB\n\n", (($total_file_size/ 1024) / 1024)); close(CMD);