superrip: check exit status of children
[cmccabe-bin] / superrip.rb
1 #!/usr/bin/ruby -w
2
3 #
4 # superrip.rb
5 #
6 # Advanced CD-ROM ripping program
7
8 # Copyright 2010, Colin McCabe
9 #
10
11 require 'fileutils'
12 require 'optparse'
13 require 'optparse/time'
14 require 'ostruct'
15
16 #-----------------------------------------------------------------
17 # constants
18 #-----------------------------------------------------------------
19 $cd_dev = "/dev/cdrom"
20 $children = Hash.new
21
22 #-----------------------------------------------------------------
23 # functions
24 #-----------------------------------------------------------------
25 def my_system(cmd)
26   puts cmd
27   system(cmd) unless $opts.dry_run == true
28   ($?.exitstatus == 0) or raise "#{cmd} failed"
29 end
30
31 def die_unless_installed(cmd)
32   system("which #{cmd} > /dev/null")
33   ($?.exitstatus == 0) or raise "you need to install the #{cmd} program"
34 end
35
36 def get_number_of_tracks_on_cd
37   look_for_tracks = false
38   lines = Array.new
39   IO.popen("cdda2wav -v summary -J dev=#{$cd_dev} 2>&1", "r") do |io|
40     io.readlines.each do |line|
41       line.chomp!
42       lines << line
43       if (line =~ /^AUDIOtrack/) then
44         look_for_tracks = true
45       elsif (look_for_tracks == true) then
46         look_for_tracks = false
47         line =~ /[ \t]*1-([ 1234567890][1234567890]*)[^1234567890]/ \
48           or raise "couldn't understand cdda2wav output! (line:#{line})"
49         return $1.to_i
50       end
51     end
52   end
53   raise "couldn't find what we were looking for in cdda2wav output! \
54 output:#{lines.join('\n')}"
55 end
56
57 # Process the WAV file into an MP3 and FLAC file.
58 # This is done in a background process.
59 def process_wav(track)
60   FileUtils.mkdir_p(track.flac_dir, $fu_args)
61   my_system("flac -f '#{track.wav_file_name}' \
62 --output-name='#{track.flac_file_name}' &>/dev/null")
63   my_system("flac --test '#{track.flac_file_name}' &>/dev/null")
64   FileUtils.mkdir_p(track.mp3_dir, $fu_args)
65   my_system("lame -q 1 -b 192 '#{track.wav_file_name}' \
66 '#{track.mp3_file_name}' &>/dev/null")
67   FileUtils.rm_f(track.wav_file_name, $fu_args)
68 end
69
70 def audiorip(tnum, track)
71   begin
72     my_system("nice -1 cdparanoia -w -d #{$cd_dev} #{tnum}")
73   rescue
74     raise "failed to rip track #{tnum} (#{track.name})"
75   end
76   # cdparanoia always outputs to cdda.wav
77   FileUtils.mv("cdda.wav", track.wav_file_name, $fu_args)
78
79   # If there are too many processes, wait for one of them to terminate
80   if ($children.keys.length > $opts.max_children) then
81     pid, status = Process.wait2(-1)
82     if (status.exitstatus != 0) then
83       raise "process #{pid} failed with exitstatus #{status.exitstatus}"
84     end
85     $children.delete(pid)
86   end
87
88   pid = Process.fork
89   if (pid == nil) then
90     retcode = 0
91     begin
92       process_wav(track)
93     rescue Exception => e
94       puts "*** FATAL ERROR: #{e}"
95       retcode = 1
96     end
97     Kernel.exit(retcode)
98   else
99     $children[pid] = 1
100   end
101 end
102
103 #-----------------------------------------------------------------
104 # classes
105 #-----------------------------------------------------------------
106 class MyOptions
107   def self.parse(args)
108     opts = OpenStruct.new
109     opts.dry_run = false
110     opts.max_children = 4
111     $fu_args = { :verbose => true }
112
113     # Fill in opts values
114     parser = OptionParser.new do |myparser|
115       myparser.banner = "Usage: #{ File.basename($0) } [opts]"
116       myparser.separator("Specific options:")
117       myparser.on("--dry-run", "-d",
118             "Show what would be done, without doing it.") do |a|
119         opts.dry_run = true
120         $fu_args = { :verbose => true, :noop => true }
121       end
122       myparser.on("--tracklist [FILE]", "-t",
123             "Provide a list of tracks to use.") do |file|
124         opts.manifest_file = file
125         opts.partial = false
126       end
127       myparser.on("--partial-tracklist [FILE]", "-T",
128             "Provide a partial list of tracks to use.") do |file|
129         opts.manifest_file = file
130         opts.partial = true
131       end
132       myparser.on("--max-children [NCHILD]", "-j",
133             "The maximum number of child processes to allow at any \
134 given time") do |nchild|
135         opts.max_children = nchild.to_i
136         if (opts.max_children < 1) then
137           raise "can't set max_children to #{opts.max_children}"
138         end
139       end
140     end
141     parser.parse!(args)
142
143     raise "you must provide a tracklist" unless opts.manifest_file != nil
144     return opts
145   end
146 end
147
148 class Track
149   attr_accessor :name, :flac_dir, :flac_file_name, :mp3_dir, :mp3_file_name,
150     :wav_file_name
151   def initialize(name)
152     if name =~ /\[LL\]/ then
153       raise "you can't include [LL] in a track name" 
154     end
155     if name =~ /\.mp3/ then
156       raise "don't include .mp3 in the track name; that will be added"
157     end
158     if name =~ /\.flac/ then
159       raise "don't include .flac in the track name; that will be added"
160     end
161     (name =~ /([^\/][^\/]*)\/([^\/]*[^\/])/) or \
162       raise "track name must be of the form 'foo/bar'"
163     @name = name
164     @flac_dir = "#{$1} [LL]"
165     @flac_file_name = "#{@flac_dir}/#{$2}.flac"
166     @mp3_dir = "#{$1}"
167     @mp3_file_name = "#{@mp3_dir}/#{$2}.mp3"
168     @wav_file_name = "#{$1}__#{$2}.wav"
169   end
170
171   def inspect
172     "track(\"#{@name}\")"
173   end
174 end
175
176 class Manifest
177   def initialize(filename)
178     @t = Hash.new
179     eval(File.new(filename).read)
180     @t.each do |key, val|
181       @t[key] = Track.new(val)
182     end
183     # TODO: implement some shortcuts that make manifests easier to type.
184     # Probably avoiding the necessity to continue typing the album name if it is the same as the
185     # previous track's name would make things a lot easier without complicating everything too much.
186   end
187
188   def validate(num_tracks)
189     if (@t.empty?) then
190       raise "you must define some tracks"
191     end
192     if (not $opts.partial) then
193       (1..num_tracks).each do |tnum|
194         if not @t.has_key?(tnum)
195           raise "don't know what to do with track #{tnum}"
196         end
197       end
198     end
199     # TODO: make sure that tracks inside albums are in order
200     # i.e. we don't map track 2 to a name that sorts to before track 1
201   end
202
203   def rip(num_tracks)
204     (1..num_tracks).each do |tnum|
205       next unless @t.has_key?(tnum)
206       audiorip(tnum, @t[tnum])
207     end
208     prc = Process.waitall
209     prc.each do |pair|
210       if (pair[1].exitstatus != 0) then
211         raise "process #{pair[0]} failed with exitstatus #{pair[1].exitstatus}"
212       end
213     end
214   end
215
216   def inspect
217     ret = ""
218     @t.keys.sort.each do |key|
219       ret = "#{ret}#{key}:'#{@t[key].inspect()}'\n"
220     end
221     return ret
222   end
223 end
224
225 #-----------------------------------------------------------------
226 # main
227 #-----------------------------------------------------------------
228 # Parse options.
229 begin
230   begin
231     $opts = MyOptions.parse(ARGV)
232   rescue ArgumentError => msg
233   $stderr.puts("#{msg} Type --help to see usage information.\n")
234   exit 1
235   end
236 end
237
238 die_unless_installed("lame")
239 die_unless_installed("flac")
240 die_unless_installed("cdparanoia")
241 die_unless_installed("cdda2wav")
242
243 manifest = Manifest.new($opts.manifest_file)
244 puts manifest.inspect
245 num_tracks = get_number_of_tracks_on_cd()
246 puts "found #{num_tracks} tracks"
247 manifest.validate(num_tracks)
248 manifest.rip(num_tracks)
249 puts "*** FINISHED ***"
250 exit 0