superrip.rb: Get basic track ripping working
[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
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   IO.popen("cdda2wav -v summary -J dev=#{$cd_dev} 2>&1", "r") do |io|
38     io.readlines.each do |line|
39       line.chomp!
40       if (line =~ /^AUDIOtrack/) then
41         look_for_tracks = true
42       elsif (look_for_tracks == true) then
43         look_for_tracks = false
44         line =~ /[ \t]*1-([1234567890][1234567890]*)[^1234567890]/ \
45           or raise "couldn't understand cdda2wav output!"
46         return $1.to_i
47       end
48     end
49   end
50   raise "couldn't find what we were looking for in cdda2wav output!"
51 end
52
53 def audiorip(tnum, track)
54   begin
55     my_system("nice -1 cdparanoia -w -d #{$cd_dev} #{tnum}")
56   rescue
57     raise "failed to rip track #{tnum} (#{track.name})"
58   end
59   # cdparanoia always outputs to cdda.wav
60   FileUtils.mv("cdda.wav", track.wav_file_name, $fu_args)
61
62   # TODO: spawn a thread to do this stuff in the background
63   FileUtils.mkdir_p(track.flac_dir, $fu_args)
64   my_system("flac -f '#{track.wav_file_name}' --output-name='#{track.flac_file_name}'")
65   begin
66     my_system("flac --test '#{track.flac_file_name}'")
67   rescue
68     raise "failed to encode #{track.flac_file_name}"
69   end
70   FileUtils.mkdir_p(track.mp3_dir, $fu_args)
71   my_system("lame -q 1 -b 192 '#{track.wav_file_name}' '#{track.mp3_file_name}'")
72   FileUtils.rm_f(track.wav_file_name, $fu_args)
73 end
74
75 #-----------------------------------------------------------------
76 # classes
77 #-----------------------------------------------------------------
78 class MyOptions
79   def self.parse(args)
80     opts = OpenStruct.new
81     opts.dry_run = false
82     $fu_args = { :verbose => true }
83
84     # Fill in opts values
85     parser = OptionParser.new do |myparser|
86       myparser.banner = "Usage: #{ File.basename($0) } [opts]"
87       myparser.separator("Specific options:")
88       myparser.on("--dry-run", "-d",
89             "Show what would be done, without doing it.") do |a|
90         opts.dry_run = true
91         $fu_args = { :verbose => true, :noop => true }
92       end
93       myparser.on("--tracklist [FILE]", "-t",
94             "Provide a list of tracks to use.") do |file|
95         opts.manifest_file = file
96         opts.partial = false
97       end
98       myparser.on("--partial-tracklist [FILE]", "-T",
99             "Provide a partial list of tracks to use.") do |file|
100         opts.manifest_file = file
101         opts.partial = true
102       end
103     end
104     parser.parse!(args)
105
106     raise "you must provide a tracklist" unless opts.manifest_file != nil
107     return opts
108   end
109 end
110
111 class Track
112   attr_accessor :name, :flac_dir, :flac_file_name, :mp3_dir, :mp3_file_name,
113     :wav_file_name
114   def initialize(name)
115     if name =~ /\[LL\]/ then
116       raise "you can't include [LL] in a track name" 
117     end
118     if name =~ /\.mp3/ then
119       raise "don't include .mp3 in the track name; that will be added"
120     end
121     if name =~ /\.flac/ then
122       raise "don't include .flac in the track name; that will be added"
123     end
124     (name =~ /([^\/][^\/]*)\/([^\/]*[^\/])/) or \
125       raise "track name must be of the form 'foo/bar'"
126     @name = name
127     @flac_dir = "#{$1} [LL]"
128     @flac_file_name = "#{@flac_dir}/#{$2}.flac"
129     @mp3_dir = "#{$1}"
130     @mp3_file_name = "#{@mp3_dir}/#{$2}.mp3"
131     @wav_file_name = "#{$1}__#{$2}.wav"
132   end
133
134   def inspect
135     "track(\"#{@name}\")"
136   end
137 end
138
139 class Manifest
140   def initialize(filename)
141     @t = Hash.new
142     eval(File.new(filename).read)
143     @t.each do |key, val|
144       @t[key] = Track.new(val)
145     end
146     # TODO: implement some shortcuts that make manifests easier to type.
147     # Probably avoiding the necessity to continue typing the album name if it is the same as the
148     # previous track's name would make things a lot easier without complicating everything too much.
149   end
150
151   def validate(num_tracks)
152     if (@t.empty?) then
153       raise "you must define some tracks"
154     end
155     @t.each { |t| t.validate }
156     if (not $opts.partial) then
157       (1..num_tracks).each do |t|
158         if not @t[t].defined?
159           raise "don't know what to do with track #{t}"
160         end
161       end
162     end
163     # TODO: make sure that tracks inside albums are in order
164     # i.e. we don't map track 2 to a name that sorts to before track 1
165   end
166
167   def rip(num_tracks)
168     (1..num_tracks).each do |tnum|
169       next unless @t.has_key?(tnum)
170       audiorip(tnum, @t[tnum])
171     end
172   end
173
174   def inspect
175     ret = ""
176     @t.keys.sort.each do |key|
177       ret = "#{ret}#{key}:'#{@t[key].inspect()}'\n"
178     end
179     return ret
180   end
181 end
182
183 #-----------------------------------------------------------------
184 # main
185 #-----------------------------------------------------------------
186 # Parse options.
187 begin
188   begin
189     $opts = MyOptions.parse(ARGV)
190   rescue ArgumentError => msg
191   $stderr.puts("#{msg} Type --help to see usage information.\n")
192   exit 1
193   end
194 end
195
196 die_unless_installed("lame")
197 die_unless_installed("flac")
198 die_unless_installed("cdparanoia")
199 die_unless_installed("cdda2wav")
200
201 manifest = Manifest.new($opts.manifest_file)
202 puts manifest.inspect
203 num_tracks = get_number_of_tracks_on_cd()
204 puts "found #{num_tracks} tracks"
205 manifest.rip(num_tracks)
206 exit 0