#!/usr/bin/perl ############################################################################### # # fysh # o # Copyright (C) 2007 Vincent Stehlé ° # (",>{ # This is free software under GPL. See documentation and license in the # embedded doc. at the end of this file. # ############################################################################### ############################################################################### # # Debug - Package for debug messages. # ############################################################################### package Debug; use warnings; use strict; BEGIN { use Exporter (); our @ISA = qw(Exporter); our @EXPORT = qw(debug DEBUG); } ############################################################################### # # DEBUG # ############################################################################### our $DEBUG = 0; ############################################################################### # # debug # ############################################################################### sub debug($) { my ($m) = @_; print $m if $DEBUG; } ############################################################################### # # Cache - Package for caching. # ############################################################################### package Cache; use warnings; use strict; use File::Copy; use Carp; use Fatal qw(:void opendir readdir closedir copy); BEGIN { Debug::->import } ############################################################################### # # read_dir_to_hash # ############################################################################### sub read_dir_to_hash($) { my ($dirname) = @_; my %r; # local *D; opendir(D, $dirname); while(my $e = readdir(D)){ next if $e eq '.'; next if $e eq '..'; $r{$e} = 1; debug("Cached $e\n"); } closedir(D); return %r; } ############################################################################### # # new # ############################################################################### sub new($$) { my ($class, $cache_dir) = @_; defined $cache_dir or confess; my $r = { # Hash, to list cached entries. # Keys are the entries names. m_entries => { read_dir_to_hash($cache_dir) }, # Path to the cache directory. m_cache_dir => $cache_dir, }; bless $r; return $r; } ############################################################################### # # add # # Description: # Add a file into the cache as 'entry'. # ############################################################################### sub add($$$) { my ($self, $file, $entry) = @_; debug("Add $file $entry\n"); copy($file, "$self->{m_cache_dir}/$entry"); $self->{m_entries}{$entry} = 1; } ############################################################################### # # query # # Description: # Ask if a file is cached. # ############################################################################### sub query($$) { my ($self, $entry) = @_; debug("Query $entry..."); my $r; $r = "$self->{m_cache_dir}/$entry" if exists $self->{m_entries}{$entry}; debug((defined $r ? $r : '') ."\n"); return $r; } ############################################################################### # # Jobs - Package for parallel jobs handling. # ############################################################################### package Jobs; use warnings; use strict; BEGIN { Debug::->import } ############################################################################### # # new # ############################################################################### sub new($$) { my ($class, $max_jobs) = @_; my $r = { # Temporary hash, to track the running jobs. # Keys are the jobs pids, values are the id passed by the caller. m_jobs => {}, # Maximum number of simultaneous jobs running. m_max_jobs => $max_jobs, # Jobs id and return codes. m_jobs_rc => [], }; bless $r; return $r; } ############################################################################### # # wait_jobs # # Description: # Wait for children until there are n running jobs # or less. # ############################################################################### sub wait_jobs($$) { my($self, $n) = @_; while(1){ my $running = scalar(keys %{$self->{m_jobs}}); debug "$running job(s) currently running.\n"; last if $running <= $n; # Wait for a job to finish. debug "Waiting...\n"; my $c = wait(); my $rc = $?; debug "-> $c ($rc)\n"; # Sanity checks. if($c < 0){ warn "wait() returned $c. Strange!\n"; return; } if(!exists $self->{m_jobs}{$c}){ warn "wait() returned $c, but job is not in" ." jobs hash. Strange!\n"; return; } # Store id and return code for later use by the caller. my $id = $self->{m_jobs}{$c}; push(@{$self->{m_jobs_rc}}, {id => $id, 'return-code' => $rc}); # Forget job. delete $self->{m_jobs}{$c}; } } ############################################################################### # # shift_rc # ############################################################################### sub shift_rc($){ shift(@{$_[0]->{m_jobs_rc}}) } ############################################################################### # # wait_for_all_jobs # # Description: # Wait for all children until there are no more running. # ############################################################################### sub wait_for_all_jobs($) { my ($self) = @_; $self->wait_jobs(0); } ############################################################################### # # wait_for_free_slot # ############################################################################### sub wait_for_free_slot($) { my ($self) = @_; $self->wait_jobs($self->{m_max_jobs} - 1); } ############################################################################### # # launch # # Description: # Run function in parallel. # # Arguments: # Function to launch. May be a perl block ({ ... }). # Id, that you get back at wait time. # # Notes: # We ensure not to exceed the maximum number of slots # allowed. # ############################################################################### sub launch($&$) { my ($self, $function, $id) = @_; # Wait for a free slot first. $self->wait_for_free_slot; # Ok, fork. debug "Forking.\n"; my $r = fork(); if(!$r){ # We are in the child. debug "Child $$ starting.\n"; # Install signal handlers. sub jobs_sig_handler() { exit 1; } $SIG{INT} = \&jobs_sig_handler; $SIG{TERM} = \&jobs_sig_handler; # Do the job. my $rc = &$function; # And exit. debug "Child $$ exiting ($rc).\n"; exit($rc); } else { # We are in the father. # Remember the child as well as its id. debug "Launched $r.\n"; $self->{m_jobs}{$r} = $id; } } ############################################################################### # # kill_all # # Description: # Kill all running jobs. # # Notes: # We use a sig term. # ############################################################################### sub kill_all($) { my ($self) = @_; kill 'TERM', keys %{$self->{m_jobs}}; } ############################################################################### # # main - Main package. # ############################################################################### package main; use warnings; use strict; use File::Copy; use Fatal qw(:void open close read seek mkdir copy unlink); use Getopt::Long; use Pod::Usage; use Fcntl qw(:seek); use Carp; BEGIN { Debug::->import } ############################################################################### # # VERSION # ############################################################################### my $VERSION = '0.5'; ############################################################################### # # CONFIG # ############################################################################### my $CONFIG = '.fyshrc'; ############################################################################### # # EXECUTABLES # ############################################################################### my @EXECUTABLES = qw(flac faac sox find python mount umount df grep sed cut wget); ############################################################################### # # ITUNESSD # # Description: # iTunesSD path, relative to iPod root. # ############################################################################### my $ITUNESSD = "iPod_Control/iTunes/iTunesSD"; ############################################################################### # # ITUNESSTATS # # Description: # iTunesStats path, relative to iPod root. # ############################################################################### my $ITUNESSTATS = "iPod_Control/iTunes/iTunesStats"; ############################################################################### # # REBUILD_DB # # Description: # Name of the rebuild_db script, as we expect it in the tgz and on the iPod. # ############################################################################### my $REBUILD_DB = "rebuild_db.py"; ############################################################################### # # REBUILD_DB_URL # ############################################################################### my $REBUILD_DB_URL = "http://mesh.dl.sourceforge.net/sourceforge/shuffle-db" ."/rebuild_db-1.0-rc1.tar.gz"; ############################################################################### # # REBUILD_DB_TGZ_DIR # # Description: # Name of the directory in the tgz. # ############################################################################### my $REBUILD_DB_TGZ_DIR = "rebuild_db"; ############################################################################### # # my_system # ############################################################################### sub my_system($) { my($c) = @_; debug "$c\n"; system($c); } ############################################################################### # # safe_system # ############################################################################### sub safe_system($) { my($c) = @_; my $r = my_system($c); confess $c if ($r >> 8); } ############################################################################### # # flac_to_m4a # # Returned value: # False in case of error. # # Notes: # Shuffle cuts @20KHz. # ############################################################################### sub flac_to_m4a($$\%) { my($flac, $m4a, $opt) = @_; # Decode. my $tmp1 = "/tmp/tmp1-$$.wav"; my $r = my_system("flac -d -o $tmp1 \"$flac\""); goto error if ($r >> 8); # Normalize. my $tmp2; if(! exists $opt->{'dont-normalize'}){ $tmp2 = "/tmp/tmp2-$$.wav"; debug "Computing optimal volume... "; my $v = `sox $tmp1 -t raw /dev/null stat -v 2>&1`; chomp $v; debug "$v\n"; if($v < $opt->{'normalize-threshold'}){ # Normalization is not worth it. debug "Below threshold: not normalizing.\n"; $tmp2 = $tmp1; } else { debug "Normalizing... "; $r = my_system("sox -v $v $tmp1 $tmp2"); goto error if ($r >> 8); debug "done\n"; unlink $tmp1; } } else { # User forbid normalization. $tmp2 = $tmp1; } # Encode. my $o = $opt->{'faac-opts'}; $o =~ s/^['"](.*)['"]$/$1/; $r = my_system("faac -o \"$m4a\" $o $tmp2"); goto error if ($r >> 8); unlink $tmp2; # Ok, exit. return 1; # In case of error, we arrive here. error: unlink $tmp1 if -e $tmp1; unlink $tmp2 if -e $tmp2; unlink $m4a if -e $m4a; return 0; } ############################################################################### # # read_bytes # # Arguments: # size: in bytes. # ############################################################################### sub read_bytes($$) { my ($F, $size) = @_; my $x; read($F, $x, $size); return $x; } ############################################################################### # # read_n_bytes # ############################################################################### sub read_n_bytes($$;$) { my ($F, $n, $little_endian) = @_; # Defaults. $little_endian = 0 if !defined $little_endian; my $x = read_bytes($F, $n); my @t = unpack("C$n", $x); # Endianess. @t = reverse @t if !$little_endian; my $r = 0; my $cnt = 0; foreach my $v (@t){ $r += ($v << (8 * $cnt++)) } return $r; } ############################################################################### # # read_string # ############################################################################### sub read_string($$) { my ($F, $size) = @_; my $x = read_bytes($F, $size); # Cope with unicode's zeroes. my @t = unpack("S*", $x); $x = pack("C*", @t); return unpack("Z*", $x); } ############################################################################### # # read_itunessd # # Notes: # We return an empty array in case of error. # ############################################################################### sub read_itunessd(\%) { my ($opt) = @_; local *F; my $filename = "$opt->{'ipod-root'}/$ITUNESSD"; open(F, $filename) or return (); binmode F; # Header. my $num_songs = read_n_bytes(*F, 3); debug "num_songs: $num_songs\n"; seek(F, 3, SEEK_CUR); # Skip 3. my $header_size = read_n_bytes(*F, 3); debug "header_size: $header_size\n"; # Each entry. my @r; for my $song (0 .. ($num_songs - 1)){ debug "song $song\n"; seek(F, ($header_size + ($song * 558) + 33), SEEK_SET); my $filename = read_string(*F, 522); debug "filename $filename\n"; push(@r, $filename); } close(F); return @r; } ############################################################################### # # read_itunesstats # # Notes: # This one is little endian. # We return an empty array in case of error. # ############################################################################### sub read_itunesstats(\%) { my ($opt) = @_; local *F; my $filename = "$opt->{'ipod-root'}/$ITUNESSTATS"; open(F, $filename) or return (); binmode F; # Header. my $num_songs = read_n_bytes(*F, 3, 1); debug "num_songs: $num_songs\n"; # Each entry. my @r; for my $song (0 .. ($num_songs - 1)){ debug "song $song\n"; seek(F, (6 + ($song * 18) + 12), SEEK_SET); my $playcount = read_n_bytes(*F, 3, 1); debug "playcount $playcount\n"; my $skippedcount = read_n_bytes(*F, 3, 1); debug "skippedcount $skippedcount\n"; push(@r, {playcount => $playcount, skippedcount => $skippedcount}); } close(F); return @r; } ############################################################################### # # remove_previous_music # # Returned value: # Hash with keys being the basenames of the removed tracks. # ############################################################################### sub remove_previous_music(\%) { my ($opt) = @_; my @sd = read_itunessd(%{$opt}); my @stats = read_itunesstats(%{$opt}); my %r; # Remove. for my $i (0 .. $#stats){ debug "$sd[$i] played=$stats[$i]->{playcount}" ." skipped=$stats[$i]->{skippedcount}\n"; if($stats[$i]->{playcount} || $stats[$i]->{skippedcount}){ my $f = "$opt->{'ipod-root'}$sd[$i]"; next if ! -e $f; debug "Removing $f\n"; unlink $f; # Remember for later re-fill, to avoid re-selecting the ones # we just removed. my $bn = $sd[$i]; $bn =~ /([^\/]+)$/; $bn = $1; $r{$bn} = 1; } } return %r; } ############################################################################### # # install_rebuild_db_if_needed # ############################################################################### sub install_rebuild_db_if_needed(\%) { my ($opt) = @_; # Check rebuild db presence. my $rdb = "$opt->{'ipod-root'}/$REBUILD_DB"; return if -e $rdb; # If we arrive here, we need to install it. print "Did not find $REBUILD_DB on iPod: installing it.\n"; # Download. my $tmp = "/tmp/tmp-$$-rebuild.tar.gz"; my $tmp_dir = "/tmp/tmp-$$-dir"; safe_system("wget -O $tmp $REBUILD_DB_URL && mkdir $tmp_dir" ."&& cd $tmp_dir && tar zxvf $tmp"); unlink $tmp; # Install. my $arc_rdb = "$tmp_dir/$REBUILD_DB_TGZ_DIR/$REBUILD_DB"; debug "Installing to $rdb\n"; copy($arc_rdb, $rdb); chmod 0755, $rdb; # Cleanup. safe_system("rm -fR $tmp_dir"); } ############################################################################### # # check_executables # ############################################################################### sub check_executables() { my $stop = 0; foreach my $e (@EXECUTABLES){ my $w = `which $e`; chomp $w; if($w eq ''){ warn "$e not in path.\n"; $stop = 1; } } if($stop){ warn "Stop."; exit 1; } } ############################################################################### # # read_config_file # ############################################################################### sub read_config_file() { my $c = "$ENV{HOME}/$CONFIG"; return if ! -e $c; local *F; open(F, $c); my @r; while(my $l = ){ chomp $l; $l =~ s/#.*$//; next if $l =~ /^\s*$/; push(@r, $l); } close(F); return @r; } ############################################################################### # # detect_num_cpu # ############################################################################### sub detect_num_cpu() { # Detect the number of cpu. # TODO: This is Linux only for now, and this # could be more elegant. my $r = `grep processor /proc/cpuinfo |wc -l`; chomp $r; debug "Detected $r cpu.\n"; return $r; } ############################################################################### # # Main. # ############################################################################### # Command line. # Shamelessly adapted from pod2usage man page. # We have default values. my %opt = ( 'faac-opts' => '', 'cache-dir' => "$ENV{HOME}/.fysh-cache", 'ipod-music-dir' => 'music', 'ipod-root' => '/media/ipod', 'normalize-threshold' => 1.05, 'rebuild-db-opts' => '', ); # Take config file into account. my @config = read_config_file(); @ARGV = (@config, @ARGV); ## Parse options and print usage if there is a syntax error, ## or if usage was explicitly requested. GetOptions(\%opt, 'cache-dir=s', 'debug', 'faac-opts=s', 'help', 'ipod-music-dir=s', 'ipod-root=s', 'man', 'max-jobs=i', 'mount', 'music-dir=s', 'normalize-threshold=f', 'no-normalize', 'no-random', 'no-rebuild-db', 'rebuild-db-opts=s', 'version') or pod2usage(2); pod2usage(1) if exists $opt{help}; pod2usage(-verbose => 2) if exists $opt{man}; # Complain on unknown arguments. pod2usage(-exitval => 2, -message => "Unknown arguments: ". join(" ", @ARGV)) if scalar(@ARGV); # Debug printing. $DEBUG = 1 if exists $opt{debug}; # Detect num cpu if no jobs limit given. $opt{'max-jobs'} = detect_num_cpu() if !exists $opt{'max-jobs'}; # Handle version. if(exists $opt{'version'}){ print < ° This is free software under GPL (see --man). (",>{ END exit; } # Initialize random seed. srand(time); # Sanity. check_executables(); # Handle mount. my_system("mount $opt{'ipod-root'}") if exists $opt{'mount'}; # Make room. my %removed = remove_previous_music(%opt); # List flac. my @l; if(exists $opt{'music-dir'}){ @l = `find $opt{'music-dir'} -type f -name \*.flac`; chomp @l; # We sort the files, as we will use random selection thereafter. @l = sort(@l); } # Ensure directory exists on the iPod. my $music_dir = "$opt{'ipod-root'}/$opt{'ipod-music-dir'}"; if(! -e $music_dir){ print "Creating $music_dir\n"; mkdir($music_dir); } # Prepare to launch jobs. my $jobs = Jobs::->new($opt{'max-jobs'}); # Register a signal handler for clean exit. sub sig_handler() { $jobs->kill_all; exit 1; } $SIG{INT} = \&sig_handler; $SIG{TERM} = \&sig_handler; # Prepare to caching. my $cache = Cache::->new($opt{'cache-dir'}); # Perform selection. my $cnt = 0; # Helper, to handle finished jobs, if there are any. # Returned value: false in case of error. sub handle_finished_jobs() { my $s = 1; # Check finished jobs if there are any. while(1){ my $r = $jobs->shift_rc; last if !defined $r; my $rc = $r->{'return-code'}; # Error? if(!$rc){ $s = 0; # Proceed with the remaining jobs. # TODO: handle differently? next; } # Ok, we have a job, which just finished ok. Retrieve the # informations we passed at launch time and handle we needs be: # i.e. move encoded file to ipod. Also, add to cache. my $i = $r->{id}; $cache->add($i->{tmp}, $i->{'cache-entry'}); if(!move($i->{tmp}, $i->{dst})){ warn "Cannot move $i->{tmp} to $i->{dst}: $!\n"; $s = 0; # In case the move fails, we stop here, as this is very likely # due to no space left on ipod. last; } } return $s; } TRACK: while(1){ # No more music? if(!@l){ print "Ran out of music!\n"; last; } # Handle finished jobs if there are any. my $r = handle_finished_jobs(); # In case of error, finish. if(!$r){ debug "Killing remaining jobs...\n"; $jobs->kill_all; last TRACK; } # TODO: # o Do not rely on error to detect disk full. # o Better error handling. # Select one flac randomly. my $i = exists $opt{'no-random'} ? $cnt : int(rand(@l)); # Remove it from list. my $flac = splice(@l, $i, 1); debug "Selecting $flac\n"; # Compute bn, tmp and dst name. my $bn = $flac; $bn =~ s/\.flac$/\.m4a/; $bn =~ s%$opt{'music-dir'}/%%; $bn =~ s%[^a-zA-Z0-9\-\.]+%_%g; my $tmp = "/tmp/tmp-$$-$cnt-$bn"; my $dst = "$music_dir/$bn"; # Skip if just removed. if(exists $removed{$bn}){ debug "Just removed: skipping.\n"; next; } # Skip if already exists. if(-f $dst){ debug "Already exists: skipping.\n"; next; } # Try cache lookup first. my $cached = $cache->query($bn); if(defined $cached){ copy($cached, $dst) } else { # Launch encoding. # Notes: # o This is blocking when the number of running jobs exceeds # a given limit. # o We supply some info as the 'id'. $jobs->launch(sub { flac_to_m4a($flac, $tmp, %opt) }, {tmp => $tmp, dst => $dst, 'cache-entry' => $bn}); } # TODO: allow "hit-under-miss" when blocked, but how? $cnt++; } # When we arrive here in case of "normal" exit (as opposed to error cases) # we may have the last jobs dispatched but still running. Wait for them. $jobs->wait_for_all_jobs; # Again, handle finished jobs if there are any. handle_finished_jobs(); # TODO: error handling? print "Put $cnt piece(s) on iPod.\n"; # Rebuild db if not forbidden to. if(!exists $opt{'no-rebuild-db'}){ install_rebuild_db_if_needed(%opt); my $o = $opt{'rebuild-db-opts'}; $o =~ s/^['"](.*)['"]$/$1/; safe_system("cd $opt{'ipod-root'} && python $REBUILD_DB $o"); } # Handle umount. my_system("umount $opt{'ipod-root'}") if exists $opt{'mount'}; ############################################################################### # # Embedded documentation. # ############################################################################### __END__ =pod =head1 NAME fysh - Fill your shuffle =head1 SYNOPSIS fysh [options] Options are: =over =item B<-c, --cache-dir> Specify cache dir. (Default: $HOME/.fysh-cache) =item B<-d, --debug> Turn on debug printing. (Default: Off) =item B<-f, --faac-opts=> Pass additional options to faac. You may enclose the string between ' or ". =item B<-h, --help> Print a brief help message and exits. =item B<--ipod-music-dir=> Specify where to put music on the iPod shuffle. This path is relative to the iPod's root. (Default: 'music') =item B<--ipod-root=> Specify where the iPod shuffle is mounted. (Default: '/media/ipod') =item B<--man> Prints the manual page and exits. =item B<--max-jobs=> Limit the maximum number of encoding jobs to launch. (Default: Number of cpu) =item B<--mount> Let fysh handle mount/umount. (Default: Do not handle mounts) =item B<--music-dir=> Specify music dir to search for flac files. =item B<--normalize-threshold=> Do not normalize sound amplitude prior to encoding if the scale factor is below the given threshold (in other words: if normalizing is not worth the effort). (Default: 1.05) =item B<--no-normalize> Do not normalize sound amplitude prior to encoding. Just leave it as it is. (Default: Normalize) =item B<--no-random> Do not pick tracks at random. This is for debug, mostly. (Default: Pick at random) =item B<--no-rebuild-db> Do not rebuild shuffle's database with rebuild_db.py. (Default: Rebuild) =item B<-r, --rebuild-db-opts=> Pass additional options to rebuild_db.py. You may enclose the string between ' or ". =item B<-v, --version> Prints version information and exits. =back =head1 DESCRIPTION fysh is meant to fill an iPod shuffle with music. It chooses, encodes, transfers and registers the music into the iPod automatically. fysh is pronounced like "fish". o ° (",>{ The first step done by fysh after its invocation is to make some room on the shuffle. To do so, the play and skip statistics are examined. All files that have been played at least once or skipped at least once are removed. The music directory tree you pass to fysh is searched for .flac files. One file is picked at random and encoded to .m4a (AAC LP). It is then copied to your iPod shuffle under the /music/ directory. The process is repeated until there are no more song available or the iPod is full. =head2 iPod shuffle directories Here is a sample iPod shuffle directory tree: / +- iPod_Control/ | `- iTunes/ +- music/ +- rebuild_db.py `- rebuild_db.log.txt Under iPod_Control/iTunes are all iTunes files, including playlist and statistics. Under music are the encoded songs. rebuild_db.py is the python script used to generate the playlist and rebuild_db.log.txt is its execution log. =head2 Config file fysh tries to read a .fyshrc in the user's home directory prior to command line arguments. The .fyshrc config file can contain all options that can be passed on the command line. Blank lines are ignored. Comments are introduced by a '#' character and last until the end of the line (shell style comments). A sample .fyshrc may read like: # .fyshrc - fysh configuration file --ipod-root=/my/ipod/mount/point --music-dir=/my/music/collection # We want fysh to automount the iPod: --mount # Adjust faac settings (iPod's passed band is 20kHz): --faac-opts='-q 90 -c 19000' =head1 EXAMPLES Invoke fysh, passing additional options to faac: $ fysh --faac-opts='-q 90 -c 19000' Invoke fysh, with a low nice value to limit cpu usage: $ nice -n 19 fysh Invoke fysh, with three encoding jobs in parallel at most: $ fysh --max-jobs=3 =head1 REFERENCES There is a webpage for fysh: http://vincent.stehle.free.fr/fysh/. File formats were taken from: http://ipodlinux.org/ITunesDB. This script relies on the python database builder of http://shuffle-db.sourceforge.net/ =head1 AUTHOR Vincent Stehlé . =head1 COPYRIGHT Copyright (C) 2007 Vincent Stehlé. This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA =head1 TODO Some enhancements remain to be done to this program: =over =item Blacklist undesired input files. =item Find latest rebuild_db.py version when downloading. =item Use GNU queue. Even better: allow arbitrary wrapper for commands. =item Able to maintain a fifo/cache and fill it speculatively. =item Better error handling. =item Able to limit fill size. =item Depend on fewer executables. =item Be able to specify removal criteria (played and/or skipped threshold). =item Be able to accumulate iTunes statistics on the host. Use them to decide which file to pick next time. =item Handle optional executables and mandatory executables in different ways during start check. =item Find a way to estimate when it is worth encoding according to the remaining size on the iPod. Today we encode, then we check if it fits. This is one encode too many. One way could be: estimate intermediate uncompressed wav size, then estimate final compressed size. then decide if it is worth trying to encode. This would necessitate a dont-estimate option too. =item Destination names computation may not suit everyone. Change this to something more flexible. =item Handle first time shuffle too. Install this dump: http://shuffle-db.sourceforge.net/ipod_root.zip =item Read bookmark from iTunes stats and use it to decide if we need to remove a file. Use a threshold? =item Handle more in and out formats. Be able to deal with other inputs than .flac: .mp3, .m4b, .aa... Some should be just copied, not encoded. =item Leave less files in the tmp when exiting abnormally. =item Normalize sound power rather than range as it is done yet? =item Be silent when more than one job? =item Progress indicator. =item More perl packages, for more modularity. =back =cut