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