3 GUI front-end to cdda2wav and lame.
5 Copyright 2004-2006 Kenneth Hayber <ken@hayber.us>
8 This program is free software; you can redistribute it and/or modify
9 it under the terms of the GNU General Public License as published by
10 the Free Software Foundation; either version 2 of the License.
12 This program is distributed in the hope that it will be useful
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 but WITHOUT ANY WARRANTY; without even the implied warranty of
15 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 GNU General Public License for more details.
18 You should have received a copy of the GNU General Public License
19 along with this program; if not, write to the Free Software
20 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
23 import gtk
, os
, sys
, signal
, re
, string
, socket
, time
, popen2
, Queue
24 from random
import Random
27 from rox
import i18n
, app_options
, Menu
, filer
, InfoWin
, tasks
28 from rox
.options
import Option
30 import PyCDDB
, cd_logic
, CDROM
, genres
38 _
= rox
.i18n
.translation(os
.path
.join(rox
.app_dir
, 'Messages'))
40 #Who am I and how did I get here?
41 APP_NAME
= 'Ripper' #I could call it Mr. Giles, but that would be gay.
42 APP_PATH
= rox
.app_dir
45 #Options.xml processing
46 from rox
import choices
47 choices
.migrate(APP_NAME
, 'hayber.us')
48 rox
.setup_app_options(APP_NAME
, site
='hayber.us')
49 Menu
.set_save_name(APP_NAME
, site
='hayber.us')
51 #assume that everyone puts their music in ~/Music
52 LIBRARY
= Option('library', '~/Music')
55 RIPPER
= Option('ripper', 'cdda2wav')
56 RIPPER_DEV
= Option('ripper_dev', '/dev/cdrom')
57 RIPPER_LUN
= Option('ripper_lun', 'ATAPI:0,1,0')
58 RIPPER_OPTS
= Option('ripper_opts', '-x -H')
60 EJECT_AFTER_RIP
= Option('eject_after_rip', '0')
63 ENCODER
= Option('encoder', 'MP3')
65 MP3_ENCODER
= Option('mp3_encoder', 'lame')
66 MP3_ENCODER_OPTS
= Option('mp3_encoder_opts', '--vbr-new -b160 --nohist --add-id3v2')
68 OGG_ENCODER
= Option('ogg_encoder', 'oggenc')
69 OGG_ENCODER_OPTS
= Option('ogg_encoder_opts', '-q5')
71 #CDDB Server and Options
72 CDDB_SERVER
= Option('cddb_server', 'http://freedb.freedb.org/~cddb/cddb.cgi')
74 rox
.app_options
.notify()
87 print >>sys
.stderr
, args
90 # My gentoo python doesn't have universal line ending support compiled in
91 # and these guys (cdda2wav and lame) use CR line endings to pretty-up their output.
93 '''Return a line of input using \r or \n as terminators'''
95 while '\n' not in line
and '\r' not in line
:
97 if char
== '': return line
103 '''Return the full path of an executable if found on the path'''
104 if (filename
== None) or (filename
== ''):
107 env_path
= os
.getenv('PATH').split(':')
109 if os
.access(p
+'/'+filename
, os
.X_OK
):
110 return p
+'/'+filename
114 def strip_illegal(instr
):
115 '''remove illegal (filename) characters from string'''
118 str = string
.translate(str, string
.maketrans(r
'/+{}*.?', r
'--()___'))
122 class Ripper(rox
.Window
):
123 '''Rip and Encode a CD'''
125 rox
.Window
.__init
__(self
)
127 self
.set_title(APP_NAME
)
128 self
.set_default_size(450, 500)
129 self
.set_position(gtk
.WIN_POS_MOUSE
)
131 #capture wm delete event
132 self
.connect("delete_event", self
.delete_event
)
134 # Update things when options change
135 rox
.app_options
.add_notify(self
.get_options
)
141 # Create layout, pack and show widgets
142 self
.vbox
= gtk
.VBox()
144 self
.vbox
.pack_start(self
.toolbar
, False, True, 0)
145 self
.vbox
.pack_start(self
.table
, False, True, 0)
146 self
.vbox
.pack_start(self
.scroll_window
, True, True, 0)
150 self
.is_ripping
= False
151 self
.is_encoding
= False
152 self
.is_cddbing
= False
153 self
.stop_request
= False
156 cd_logic
.set_dev(RIPPER_DEV
.value
)
157 self
.cd_status
= cd_logic
.check_dev()
159 self
.cd_status_changed
= False
161 if self
.cd_status
in [CDROM
.CDS_TRAY_OPEN
, CDROM
.CDS_NO_DISC
]:
166 tasks
.Task(self
.update_gui())
169 def build_menu(self
):
170 self
.add_events(gtk
.gdk
.BUTTON_PRESS_MASK
)
171 self
.connect('button-press-event', self
.button_press
)
172 self
.view
.add_events(gtk
.gdk
.BUTTON_PRESS_MASK
)
173 self
.view
.connect('button-press-event', self
.button_press
)
175 self
.menu
= Menu
.Menu('main', [
176 Menu
.Action(_('Rip & Encode'), 'rip_n_encode', '', gtk
.STOCK_EXECUTE
),
177 Menu
.Action(_('Reload CD'), 'do_get_tracks', '', gtk
.STOCK_REFRESH
),
178 Menu
.Action(_('Stop'), 'stop', '', gtk
.STOCK_STOP
),
180 Menu
.Action(_('Options'), 'show_options', '', gtk
.STOCK_PREFERENCES
),
181 Menu
.Action(_('Info'), 'get_info', '', gtk
.STOCK_DIALOG_INFO
),
182 Menu
.Action(_("Quit"), 'close', '', gtk
.STOCK_CLOSE
),
184 self
.menu
.attach(self
,self
)
187 def build_toolbar(self
):
188 self
.toolbar
= gtk
.Toolbar()
189 self
.toolbar
.set_style(gtk
.TOOLBAR_ICONS
)
190 self
.toolbar
.insert_stock(gtk
.STOCK_PREFERENCES
, _('Settings'), None, self
.show_options
, None, 0)
191 self
.stop_btn
= self
.toolbar
.insert_stock(gtk
.STOCK_STOP
, _('Stop'), None, self
.stop
, None, 0)
192 self
.rip_btn
= self
.toolbar
.insert_stock(gtk
.STOCK_EXECUTE
, _('Rip & Encode'), None, self
.rip_n_encode
, None, 0)
193 self
.refresh_btn
= self
.toolbar
.insert_stock(gtk
.STOCK_REFRESH
, _('Reload CD'), None, self
.do_get_tracks
, None, 0)
194 self
.toolbar
.insert_stock(gtk
.STOCK_GO_UP
, _('Show destination dir'), None, self
.show_dir
, None, 0)
195 self
.toolbar
.insert_stock(gtk
.STOCK_CLOSE
, _('Close'), None, self
.close
, None, 0)
199 swin
= gtk
.ScrolledWindow()
200 self
.scroll_window
= swin
201 swin
.set_policy(gtk
.POLICY_AUTOMATIC
, gtk
.POLICY_AUTOMATIC
)
202 swin
.set_shadow_type(gtk
.SHADOW_IN
)
204 self
.store
= gtk
.ListStore(int, str, str, str)
205 view
= gtk
.TreeView(self
.store
)
208 view
.set_rules_hint(True)
210 cell
= gtk
.CellRendererToggle()
211 cell
.connect('toggled', self
.toggle_check
)
212 column
= gtk
.TreeViewColumn('', cell
, active
=COL_ENABLE
)
213 view
.append_column(column
)
214 column
.set_resizable(False)
215 column
.set_reorderable(False)
217 cell
= gtk
.CellRendererText()
218 column
= gtk
.TreeViewColumn(_('Track'), cell
, text
= COL_TRACK
)
219 view
.append_column(column
)
220 column
.set_resizable(True)
221 column
.set_reorderable(False)
223 cell
= gtk
.CellRendererText()
224 column
= gtk
.TreeViewColumn(_('Time'), cell
, text
= COL_TIME
)
225 view
.append_column(column
)
226 column
.set_resizable(True)
227 column
.set_reorderable(False)
229 cell
= gtk
.CellRendererText()
230 column
= gtk
.TreeViewColumn(_('Status'), cell
, text
= COL_STATUS
)
231 view
.append_column(column
)
232 column
.set_resizable(True)
233 column
.set_reorderable(False)
235 view
.connect('row-activated', self
.activate
)
236 self
.selection
= view
.get_selection()
237 self
.handler
= self
.selection
.connect('changed', self
.set_selection
)
239 self
.table
= gtk
.Table(5, 2, False)
243 self
.artist_entry
= gtk
.Entry(max=255)
244 self
.artist_entry
.connect('changed', self
.stuff_changed
)
245 self
.table
.attach(gtk
.Label(str=_('Artist')), 0, 1, 2, 3, 0, 0, 4, y_pad
)
246 self
.table
.attach(self
.artist_entry
, 1, 2, 2, 3, gtk
.EXPAND|gtk
.FILL
, 0, x_pad
, y_pad
)
248 self
.album_entry
= gtk
.Entry(max=255)
249 self
.album_entry
.connect('changed', self
.stuff_changed
)
250 self
.table
.attach(gtk
.Label(str=_('Album')), 0, 1, 3, 4, 0, 0, 4, y_pad
)
251 self
.table
.attach(self
.album_entry
, 1, 2, 3, 4, gtk
.EXPAND|gtk
.FILL
, 0, x_pad
, y_pad
)
253 genres
.genre_list
.sort()
254 self
.genre_combo
= gtk
.Combo()
255 self
.genre_combo
.set_popdown_strings(genres
.genre_list
)
256 self
.genre_combo
.entry
.connect('changed', self
.stuff_changed
)
257 self
.table
.attach(gtk
.Label(str=_('Genre')), 0, 1, 4, 5, 0, 0, 4, y_pad
)
258 self
.table
.attach(self
.genre_combo
, 1, 2, 4, 5, gtk
.EXPAND|gtk
.FILL
, 0, x_pad
, y_pad
)
260 self
.year_entry
= gtk
.Entry(max=4)
261 self
.year_entry
.connect('changed', self
.stuff_changed
)
262 self
.table
.attach(gtk
.Label(str=_('Year')), 0, 1, 5, 6, 0, 0, 4, y_pad
)
263 self
.table
.attach(self
.year_entry
, 1, 2, 5, 6, gtk
.EXPAND|gtk
.FILL
, 0, x_pad
, y_pad
)
266 def update_gui(self
):
267 '''Update UI based on current state'''
269 cd_status
= cd_logic
.check_dev()
270 if self
.cd_status
!= cd_status
:
271 self
.cd_status
= cd_status
272 self
.cd_status_changed
= True
274 if self
.is_ripping
or self
.is_encoding
:
275 self
.stop_btn
.set_sensitive(True)
276 self
.rip_btn
.set_sensitive(False)
277 self
.refresh_btn
.set_sensitive(False)
279 if not self
.is_ripping
and not self
.is_encoding
and not self
.is_cddbing
:
280 self
.stop_btn
.set_sensitive(False)
281 self
.rip_btn
.set_sensitive(True)
282 self
.refresh_btn
.set_sensitive(True)
284 #get tracks if cd changed and not doing other things
285 if self
.cd_status_changed
:
286 if self
.cd_status
in [CDROM
.CDS_TRAY_OPEN
, CDROM
.CDS_NO_DISC
]:
290 disc_id
= cd_logic
.get_disc_id()
291 if self
.disc_id
<> disc_id
:
292 self
.disc_id
= disc_id
294 self
.cd_status_changed
= False
299 yield tasks
.TimeoutBlocker(1)
302 def stuff_changed(self
, button
=None):
303 '''Get new text from edit boxes and save it'''
304 self
.genre
= self
.genre_combo
.entry
.get_text()
305 self
.artist
= self
.artist_entry
.get_text()
306 self
.album
= self
.album_entry
.get_text()
307 self
.year
= self
.year_entry
.get_text()
311 '''Stop current rip/encode process'''
312 self
.stop_request
= True
315 def show_dir(self
, *dummy
):
316 ''' Pops up a filer window. '''
317 temp
= os
.path
.join(os
.path
.expanduser(LIBRARY
.value
), self
.artist
, self
.album
)
318 filer
.show_file(temp
)
321 def do_get_tracks(self
, button
=None):
322 '''Get the track info (cddb and cd) in a thread'''
325 tasks
.Task(self
.get_tracks())
329 '''Clear all info and display <no disc>'''
330 dbg("no disc in tray?")
332 self
.artist_entry
.set_text(_('<no disc>'))
333 self
.album_entry
.set_text('')
334 self
.genre_combo
.entry
.set_text('')
335 self
.year_entry
.set_text('')
336 self
.view
.columns_autosize()
339 def get_tracks(self
):
340 '''Get the track info (cddb and cd)'''
341 self
.is_cddbing
= True
342 stuff
= self
.get_cddb()
343 (count
, artist
, album
, genre
, year
, tracklist
) = stuff
352 self
.tracklist
= tracklist
354 if artist
: self
.artist_entry
.set_text(artist
)
355 if album
: self
.album_entry
.set_text(album
)
356 if genre
: self
.genre_combo
.entry
.set_text(genre
)
357 if year
: self
.year_entry
.set_text(year
)
360 for track
in tracklist
:
362 iter = self
.store
.append(None)
363 self
.store
.set(iter, COL_TRACK
, track
[0])
364 self
.store
.set(iter, COL_TIME
, track
[1])
365 self
.store
.set(iter, COL_ENABLE
, True)
368 self
.view
.columns_autosize()
369 self
.is_cddbing
= False
373 '''Query cddb for track and cd info'''
374 dlg
= gtk
.MessageDialog(buttons
=gtk
.BUTTONS_CANCEL
, message_format
="Getting Track Info.")
376 while gtk
.events_pending():
379 count
= artist
= genre
= album
= year
= ''
383 #Note: all the nested try|except|pass statements are to ensure that as much
384 #info is processed as possible. One exception should not stop
385 #the whole thing and return nothing.
388 count
= cd_logic
.total_tracks()
389 cddb_id
= cd_logic
.get_cddb_id()
391 #PyCDDB wants a string delimited by spaces, go figure.
394 cddb_id_string
+= str(n
)+' '
395 #dbg(disc_id, cddb_id, cddb_id_string)
397 for i
in range(count
):
398 tracktime
= cd_logic
.get_track_time_total(i
+1)
399 track_time
= time
.strftime('%M:%S', time
.gmtime(tracktime
))
400 tracklist
.append((_('Track')+`i`
,track_time
))
403 db
= PyCDDB
.PyCDDB(CDDB_SERVER
.value
)
404 query_info
= db
.query(cddb_id_string
)
407 dlg
.set_title(_('Got Disc Info'))
408 while gtk
.events_pending():
411 #make sure we didn't get an error, then query CDDB
412 if len(query_info
) > 0:
414 index
= rndm
.randrange(0, len(query_info
))
415 read_info
= db
.read(query_info
[index
])
417 dlg
.set_title(_('Got Track Info'))
418 while gtk
.events_pending():
422 (artist
, album
) = query_info
[index
]['title'].split('/')
423 artist
= artist
.strip().decode('latin-1', 'replace')
424 album
= album
.strip().decode('latin-1', 'replace')
425 genre
= query_info
[index
]['category']
426 if genre
in ['misc', 'data']:
429 #dbg(query_info['year'])
430 #dbg(read_info['EXTD'])
431 #dbg(read_info['YEARD'])
433 #x = re.match(r'.*YEAR: (.+).*',read_info['EXTD'])
440 if len(read_info
['TTITLE']) > 0:
441 for i
in range(count
):
443 track_name
= read_info
['TTITLE'][i
].decode('latin-1', 'replace')
444 track_time
= tracklist
[i
][1]
445 #dbg(i, track_name, track_time)
446 tracklist
[i
] = (track_name
, track_time
)
456 return count
, artist
, album
, genre
, year
, tracklist
459 def run_cdda2wav(self
, tracknum
, track
):
460 '''Run cdda2wav to rip a track from the CD'''
461 cdda2wav_cmd
= RIPPER
.value
462 cdda2wav_dev
= RIPPER_DEV
.value
463 cdda2wav_lun
= RIPPER_LUN
.value
464 cdda2wav_args
= '-g -D%s -A%s -t %d "%s"' % (
465 cdda2wav_lun
, cdda2wav_dev
, tracknum
+1, strip_illegal(track
))
466 cdda2wav_opts
= RIPPER_OPTS
.value
468 thing
= popen2
.Popen4(cdda2wav_cmd
+' '+cdda2wav_opts
+' '+cdda2wav_args
)
472 def run_lame(self
, tracknum
, track
, artist
, genre
, album
, year
):
473 '''Run lame to encode a wav file to mp3'''
479 lame_cmd
= MP3_ENCODER
.value
480 lame_opts
= MP3_ENCODER_OPTS
.value
481 lame_tags
= '--ta "%s" --tt "%s" --tl "%s" --tg "%s" --tn %d --ty %d' % (
482 artist
, track
, album
, genre
, tracknum
+1, int_year
)
483 lame_args
= '"%s" "%s"' % (strip_illegal(track
)+'.wav', strip_illegal(track
)+'.mp3')
485 #dbg(lame_opts, lame_tags, lame_args)
486 thing
= popen2
.Popen4(lame_cmd
+' '+lame_opts
+' '+lame_tags
+' '+lame_args
)
490 def run_ogg(self
, tracknum
, track
, artist
, genre
, album
, year
):
491 '''Run oggenc to encode a wav file to ogg'''
497 ogg_cmd
= OGG_ENCODER
.value
498 ogg_opts
= OGG_ENCODER_OPTS
.value
499 ogg_tags
= '-a "%s" -t "%s" -l "%s" -G "%s" -N %d -d %d' % (
500 artist
, track
, album
, genre
, tracknum
+1, int_year
)
501 ogg_args
= '"%s"' % (strip_illegal(track
)+'.wav')
503 #dbg(ogg_opts, ogg_tags, ogg_args)
504 thing
= popen2
.Popen4(ogg_cmd
+' '+ogg_opts
+' '+ogg_tags
+' '+ogg_args
)
508 def rip_n_encode(self
, button
=None):
509 '''Process all selected tracks (rip and encode)'''
511 os
.chdir(os
.path
.expanduser(LIBRARY
.value
))
514 os
.mkdir(os
.path
.expanduser(LIBRARY
.value
))
515 os
.chdir(os
.path
.expanduser(LIBRARY
.value
))
517 rox
.alert("Failed to find or create Library dir")
519 if self
.count
and self
.artist
and self
.album
:
520 try: os
.mkdir(self
.artist
)
523 try: os
.mkdir(self
.artist
+'/'+self
.album
)
526 try: os
.chdir(self
.artist
+'/'+self
.album
)
529 self
.stop_request
= False
531 #the queue to feed tracks from ripper to encoder
532 self
.wavqueue
= Queue
.Queue(1000)
534 tasks
.Task(self
.ripit())
535 tasks
.Task(self
.encodeit())
539 '''Thread to rip all selected tracks'''
540 self
.is_ripping
= True
541 for tracknum
in range(self
.count
):
542 if self
.stop_request
:
545 if self
.store
[tracknum
][COL_ENABLE
]:
546 track
= self
.store
[tracknum
][COL_TRACK
]
547 thing
= self
.run_cdda2wav(tracknum
, track
)
548 outfile
= thing
.fromchild
550 blocker
= tasks
.InputBlocker(outfile
)
553 if self
.stop_request
:
554 os
.kill(thing
.pid
, signal
.SIGKILL
)
557 line
= myreadline(outfile
)
559 x
= re
.match(".*([ 0-9][0-9])%", line
)
561 percent
= int(x
.group(1))
562 self
.status_update(tracknum
, 'rip', percent
)
566 status
= thing
.wait()
567 self
.status_update(tracknum
, 'rip', 100)
570 #dbg('cdda2wav died %d' % status)
571 self
.status_update(tracknum
, 'rip_error', 0)
573 #push this track on the queue for the encoder
575 self
.wavqueue
.put((track
, tracknum
))
577 #push None object to tell encoder we're done
579 self
.wavqueue
.put((None, None))
581 self
.is_ripping
= False
583 if EJECT_AFTER_RIP
.int_value
:
588 '''Thread to encode all tracks from the wavqueue'''
589 self
.is_encoding
= True
591 if self
.stop_request
:
595 (track
, tracknum
) = self
.wavqueue
.get(False)
597 yield tasks
.TimeoutBlocker(1)
603 if ENCODER
.value
== 'MP3':
604 thing
= self
.run_lame(tracknum
, track
, self
.artist
, self
.genre
, self
.album
, self
.year
)
606 thing
= self
.run_ogg(tracknum
, track
, self
.artist
, self
.genre
, self
.album
, self
.year
)
608 outfile
= thing
.fromchild
610 blocker
= tasks
.InputBlocker(outfile
)
613 if self
.stop_request
:
614 os
.kill(thing
.pid
, signal
.SIGKILL
)
617 line
= myreadline(outfile
)
620 if ENCODER
.value
== 'MP3':
621 x
= re
.match(r
"^[\s]+([0-9]+)/([0-9]+)", line
)
623 percent
= int(100 * (float(x
.group(1)) / float(x
.group(2))))
624 self
.status_update(tracknum
, 'enc', percent
)
626 x
= re
.match('^.*\[[\s]*([.0-9]+)%\]', line
)
628 percent
= float(x
.group(1))
629 self
.status_update(tracknum
, 'enc', percent
)
633 status
= thing
.wait()
634 self
.status_update(tracknum
, 'enc', 100)
637 #dbg('encoder died %d' % status)
638 self
.status_update(tracknum
, 'enc_error', 0)
640 try: os
.unlink(strip_illegal(track
)+".wav")
642 try: os
.unlink(strip_illegal(track
)+".inf")
645 self
.is_encoding
= False
648 def status_update(self
, row
, state
, percent
):
649 '''Callback from rip/encode tasks to update display'''
650 iter = self
.store
.get_iter((row
,))
654 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': %d%%' % percent
)
656 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': '+_('done'))
659 self
.store
.set_value(iter, COL_STATUS
, _('Encoding')+': %d%%' % percent
)
661 self
.store
.set_value(iter, COL_STATUS
, _('Encoding')+': '+_('done'))
662 if state
== 'rip_error':
663 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': '+_('error'))
664 if state
== 'enc_error':
665 self
.store
.set_value(iter, COL_STATUS
, _('Encoding')+': '+_('error'))
668 def activate(self
, view
, path
, column
):
669 '''Edit a track name'''
670 model
, iter = self
.view
.get_selection().get_selected()
672 track
= model
.get_value(iter, COL_TRACK
)
673 dlg
= gtk
.Dialog(APP_NAME
)
674 dlg
.set_default_size(350, 100)
678 entry
.set_text(track
)
680 entry
.set_activates_default(True)
681 dlg
.vbox
.pack_start(entry
)
683 dlg
.add_button(gtk
.STOCK_OK
, gtk
.RESPONSE_OK
)
684 dlg
.add_button(gtk
.STOCK_CANCEL
, gtk
.RESPONSE_CANCEL
)
685 dlg
.set_default_response(gtk
.RESPONSE_OK
)
688 if response
== gtk
.RESPONSE_OK
:
689 track
= entry
.get_text()
691 model
.set_value(iter, COL_TRACK
, track
)
692 self
.view
.columns_autosize()
697 def toggle_check(self
, cell
, rownum
):
698 '''Toggle state for each song'''
699 row
= self
.store
[rownum
]
700 row
[COL_ENABLE
] = not row
[COL_ENABLE
]
701 self
.store
.row_changed(rownum
, row
.iter)
704 def set_selection(self
, thing
):
705 '''Get current selection'''
706 #model, iter = self.view.get_selection().get_selected()
708 # track = model.get_value(iter, COL_TRACK)
710 def button_press(self
, text
, event
):
711 '''Popup menu handler'''
712 if event
.button
!= 3:
714 self
.menu
.popup(self
, event
)
717 def show_options(self
, button
=None):
718 '''Show Options dialog'''
721 def get_options(self
):
722 '''Get changed Options'''
726 InfoWin
.infowin(APP_NAME
)
728 def delete_event(self
, ev
, e1
):
732 def close(self
, button
= None):
733 '''We are outta here!'''