First commit
[rox-ripper.git] / ripper.py
blob40191c9e116ebf8aac119448329a9ce60c9638dd
1 """
2 ripper.py
3 GUI front-end to cdda2wav and lame.
5 Copyright 2004 Kenneth Hayber <khayber@socal.rr.com>
6 All rights reserved.
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 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 GNU General Public License for more details.
17 You should have received a copy of the GNU General Public License
18 along with this program; if not, write to the Free Software
19 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
20 """
22 import rox
23 from rox import g, i18n, app_options
24 import os, sys, signal, re, string, socket, time, popen2, threading, Queue
25 from threading import *
26 from rox.options import Option
28 socket.setdefaulttimeout(30) #CDDB doesn't do this and will wait forever.
29 import PyCDDB, cd_logic
31 _ = rox.i18n.translation(os.path.join(rox.app_dir, 'Messages'))
33 #Who am I and how did I get here?
34 APP_NAME = 'Ripper' #I could call it Mr. Giles, but that would be gay.
35 APP_PATH = os.path.split(os.path.abspath(sys.argv[0]))[0]
38 #Options.xml processing
39 rox.setup_app_options(APP_NAME)
41 #assume that everyone puts their music in ~/Music
42 LIBRARY = Option('library', os.path.expanduser('~')+'/MyMusic')
44 RIPPER = Option('ripper', 'cdda2wav')
45 RIPPER_DEV = Option('ripper_dev', '/dev/cdrom')
46 RIPPER_LUN = Option('ripper_lun', 'ATAPI:0,1,0')
47 RIPPER_OPTS = Option('ripper_opts', '-x -H')
49 ENCODER = Option('encoder', 'lame')
50 ENCODER_OPTS = Option('encoder_opts', '--vbr-new -b160 --nohist --add-id3v2')
53 #CDDB Server and Options
54 CDDB_SERVER = Option('cddb_server', 'http://freedb.freedb.org/~cddb/cddb.cgi')
56 #ENCODER options
57 #RIPPER options
59 rox.app_options.notify()
62 #Column indicies
63 COL_ENABLE = 0
64 COL_TRACK = 1
65 COL_TIME = 2
66 COL_STATUS = 3
69 # My gentoo python doesn't have universal line ending support compiled in
70 # and these guys (cdda2wav and lame) use CR line endings to pretty-up their output.
71 def myreadline(file):
72 '''Return a line of input using \r or \n as terminators'''
73 line = ''
74 while '\n' not in line and '\r' not in line:
75 char = file.read(1)
76 if char == '': return line
77 line += char
78 return line
81 def which(filename):
82 '''Return the full path of an executable if found on the path'''
83 if (filename == None) or (filename == ''):
84 return None
86 env_path = os.getenv('PATH').split(':')
87 for p in env_path:
88 if os.access(p+'/'+filename, os.X_OK):
89 return p+'/'+filename
90 return None
93 def strip_illegal(instr):
94 '''remove illegal (filename) characters from string'''
95 str = instr
96 str = str.strip()
97 str = string.translate(str, string.maketrans(r'/+{}*.?', r'--()___'))
98 return str
101 class Ripper(rox.Window):
102 '''Rip and Encode a CD'''
103 def __init__(self):
104 rox.Window.__init__(self)
106 self.set_title(APP_NAME)
107 self.set_border_width(1)
108 self.set_default_size(450, 500)
109 self.set_position(g.WIN_POS_MOUSE)
111 #capture wm delete event
112 self.connect("delete_event", self.delete_event)
114 # Update things when options change
115 rox.app_options.add_notify(self.get_options)
118 #song list
119 #######################################
120 swin = g.ScrolledWindow()
121 self.scroll_window = swin
123 swin.set_border_width(3)
124 swin.set_policy(g.POLICY_AUTOMATIC, g.POLICY_AUTOMATIC)
125 swin.set_shadow_type(g.SHADOW_IN)
127 self.store = g.ListStore(int, str, str, str)
128 view = g.TreeView(self.store)
129 self.view = view
130 swin.add(view)
131 view.set_rules_hint(True)
133 # self.view.add_events(g.gdk.BUTTON_PRESS_MASK)
134 # self.view.connect('button-press-event', self.button_press)
136 cell = g.CellRendererToggle()
137 cell.connect('toggled', self.toggle_check)
138 column = g.TreeViewColumn('', cell, active=COL_ENABLE)
139 view.append_column(column)
140 column.set_resizable(False)
141 column.set_reorderable(False)
143 cell = g.CellRendererText()
144 column = g.TreeViewColumn(_('Track'), cell, text = COL_TRACK)
145 view.append_column(column)
146 column.set_resizable(True)
147 column.set_reorderable(False)
149 cell = g.CellRendererText()
150 column = g.TreeViewColumn(_('Time'), cell, text = COL_TIME)
151 view.append_column(column)
152 column.set_resizable(True)
153 column.set_reorderable(False)
155 cell = g.CellRendererText()
156 column = g.TreeViewColumn(_('Status'), cell, text = COL_STATUS)
157 view.append_column(column)
158 column.set_resizable(True)
159 column.set_reorderable(False)
161 view.connect('row-activated', self.activate)
162 self.selection = view.get_selection()
163 self.handler = self.selection.connect('changed', self.set_selection)
166 self.toolbar = g.Toolbar()
167 self.toolbar.set_style(g.TOOLBAR_ICONS)
168 self.toolbar.insert_stock(g.STOCK_PREFERENCES,
169 _('Settings'), None, self.show_options, None, 0)
170 self.stop_btn = self.toolbar.insert_stock(g.STOCK_STOP,
171 _('Stop'), None, self.stop, None, 0)
172 self.rip_btn = self.toolbar.insert_stock(g.STOCK_EXECUTE,
173 _('Rip & Encode'), None, self.rip_n_encode, None, 0)
174 self.refresh_btn = self.toolbar.insert_stock(g.STOCK_REFRESH,
175 _('Reload CD'), None, self.do_get_tracks, None, 0)
178 self.table = g.Table(1, 5, g.FALSE)
179 x_pad = 2
180 y_pad = 1
182 self.artist_entry = g.Entry(max=255)
183 self.artist_entry.connect('changed', self.stuff_changed)
184 self.table.attach(g.Label(str=_('Artist')), 0, 1, 2, 3, 0, 0, 4, y_pad)
185 self.table.attach(self.artist_entry, 1, 2, 2, 3, g.EXPAND|g.FILL, 0, x_pad, y_pad)
187 self.album_entry = g.Entry(max=255)
188 self.album_entry.connect('changed', self.stuff_changed)
189 self.table.attach(g.Label(str=_('Album')), 0, 1, 3, 4, 0, 0, 4, y_pad)
190 self.table.attach(self.album_entry, 1, 2, 3, 4, g.EXPAND|g.FILL, 0, x_pad, y_pad)
192 self.genre_entry = g.Entry(max=255)
193 self.genre_entry.connect('changed', self.stuff_changed)
194 self.table.attach(g.Label(str=_('Genre')), 0, 1, 4, 5, 0, 0, 4, y_pad)
195 self.table.attach(self.genre_entry, 1, 2, 4, 5, g.EXPAND|g.FILL, 0, x_pad, y_pad)
197 self.year_entry = g.Entry(max=4)
198 self.year_entry.connect('changed', self.stuff_changed)
199 self.table.attach(g.Label(str=_('Year')), 0, 1, 5, 6, 0, 0, 4, y_pad)
200 self.table.attach(self.year_entry, 1, 2, 5, 6, g.EXPAND|g.FILL, 0, x_pad, y_pad)
203 # Create layout, pack and show widgets
204 self.vbox = g.VBox()
205 self.add(self.vbox)
206 self.vbox.pack_start(self.toolbar, False, True, 0)
207 self.vbox.pack_start(self.table, False, True, 0)
208 self.vbox.pack_start(self.scroll_window, True, True, 0)
209 self.vbox.show_all()
211 self.cddb_thd = None
212 self.ripper_thd = None
213 self.encoder_thd = None
214 self.is_ripping = False
215 self.is_encoding = False
216 self.please_stop = False
218 cd_logic.set_dev(RIPPER_DEV.value)
219 self.cd_status = cd_logic.check_dev()
221 self.disc_id = None
222 self.do_get_tracks()
224 # Set up thread for GUI updates
225 g.timeout_add(1000, self.update_gui)
228 def update_gui(self):
229 '''Update button status based on current state'''
230 if self.is_ripping or self.is_encoding:
231 self.stop_btn.set_sensitive(True)
232 self.rip_btn.set_sensitive(False)
233 self.refresh_btn.set_sensitive(False)
235 if not self.is_ripping and not self.is_encoding:
236 self.stop_btn.set_sensitive(False)
237 self.rip_btn.set_sensitive(True)
238 self.refresh_btn.set_sensitive(True)
240 #get tracks if cd changed
241 try:
242 cd_status = cd_logic.check_dev()
243 if self.cd_status != cd_status:
244 self.cd_status = cd_status
245 # cd_logic.get_disc_id()
246 # if self.disc_id <> disc_id:
247 # print self.disc_id, disc_id
248 self.do_get_tracks()
249 except: pass
251 return True
254 def stuff_changed(self, button=None):
255 '''Get new text from edit boxes and save it'''
256 self.genre = self.genre_entry.get_text()
257 self.artist = self.artist_entry.get_text()
258 self.album = self.album_entry.get_text()
259 self.year = self.year_entry.get_text()
262 def runit(self, it):
263 '''Run a function in a thread'''
264 thd_it = Thread(name='mythread', target=it)
265 thd_it.setDaemon(True)
266 thd_it.start()
267 return thd_it
270 def stop(self, it):
271 '''Stop current rip/encode process'''
272 self.please_stop = True
275 def do_get_tracks(self, button=None):
276 '''Get the track info (cddb and cd) in a thread'''
277 if self.is_ripping:
278 return
279 self.cddb_thd = self.runit(self.get_tracks)
282 def get_tracks(self):
283 '''Get the track info (cddb and cd)'''
284 stuff = self.get_cddb()
285 if stuff == False:
286 #print "no disc in tray?"
287 g.threads_enter()
288 self.store.clear()
289 self.artist_entry.set_text('<no disc>')
290 self.album_entry.set_text('')
291 self.genre_entry.set_text('')
292 self.year_entry.set_text('')
293 g.threads_leave()
294 return
295 else:
296 (count, artist, album, genre, year, tracklist) = stuff
297 #print count, artist, album, genre, year, tracklist
299 self.artist = artist
300 self.count = count
301 self.album = album
302 self.genre = genre
303 self.year = year
304 self.tracklist = tracklist
306 g.threads_enter()
308 if artist: self.artist_entry.set_text(artist)
309 if album: self.album_entry.set_text(album)
310 if genre: self.genre_entry.set_text(genre)
311 if year: self.year_entry.set_text(year)
313 for track in tracklist:
314 #print song
315 iter = self.store.append(None)
316 self.store.set(iter, COL_TRACK, track[0])
317 self.store.set(iter, COL_TIME, track[1])
318 self.store.set(iter, COL_ENABLE, True)
320 g.threads_leave()
323 def get_cddb(self):
324 '''Query cddb for track and cd info'''
325 g.threads_enter()
326 dlg = g.MessageDialog(buttons=g.BUTTONS_CANCEL, message_format="Getting Track Info.")
327 dlg.set_position(g.WIN_POS_NONE)
328 (a, b) = dlg.get_size()
329 (x, y) = self.get_position()
330 (dx, dy) = self.get_size()
331 dlg.move(x+dx/2-a/2, y+dy/2-b/2)
332 dlg.show()
333 g.threads_leave()
335 count = artist = genre = album = year = None
336 tracklist = []
337 tracktime = []
339 try:
340 disc_id = cd_logic.get_disc_id()
341 self.disc_id = disc_id
342 #print disc_id
344 g.threads_enter()
345 dlg.set_title('Got Disc ID')
346 g.threads_leave()
348 count = cd_logic.total_tracks()
349 cddb_id = cd_logic.get_cddb_id()
351 #PyCDDB wants a string delimited by spaces, go figure.
352 cddb_id_string = ''
353 for n in cddb_id:
354 cddb_id_string += str(n)+' '
355 #print cddb_id, cddb_id_string
357 for i in range(count):
358 tracktime = cd_logic.get_track_time_total(i)
359 track_time = time.strftime('%M:%S', time.gmtime(tracktime))
360 tracklist.append((_('Track')+`i`,track_time))
362 try:
363 db = PyCDDB.PyCDDB(CDDB_SERVER.value)
364 query_info = db.query(cddb_id_string)
365 #print query_info
367 g.threads_enter()
368 dlg.set_title('Got Disc Info')
369 g.threads_leave()
371 #make sure we didn't get an error, then query CDDB
372 if len(query_info) > 0:
373 index = 0 #but we might want to choose one of the others?
374 read_info = db.read(query_info[index])
375 #print read_info
376 g.threads_enter()
377 dlg.set_title('Got Track Info')
378 g.threads_leave()
380 try:
381 (artist, album) = query_info[index]['title'].split('/')
382 artist = artist.strip()
383 album = album.strip()
384 genre = query_info[index]['category']
385 if genre in ['misc', 'data']:
386 genre = 'Other'
388 print query_info['year']
389 print read_info['EXTD']
390 print read_info['YEARD']
392 #x = re.match(r'.*YEAR: (.+).*',read_info['EXTD'])
393 #if x:
394 # print x.group(1)
395 # year = x.group(1)
396 except:
397 pass
399 if len(read_info['TTITLE']) > 0:
400 for i in range(count):
401 try:
402 track_name = read_info['TTITLE'][i]
403 track_time = tracklist[i][1]
404 #print i, track_name, track_time
405 tracklist[i] = (track_name, track_time)
406 except:
407 pass
408 except:
409 pass
411 except:
412 g.threads_enter()
413 dlg.destroy()
414 g.threads_leave()
415 return False
417 g.threads_enter()
418 dlg.destroy()
419 g.threads_leave()
420 return count, artist, album, genre, year, tracklist
423 def get_cdda2wav(self, tracknum, track):
424 '''Run cdda2wav to rip a track from the CD'''
425 cdda2wav_cmd = RIPPER.value
426 cdda2wav_dev = RIPPER_DEV.value
427 cdda2wav_lun = RIPPER_LUN.value
428 cdda2wav_args = '-D%s -A%s -t %d "%s"' % (
429 cdda2wav_lun, cdda2wav_dev, tracknum+1, strip_illegal(track))
430 cdda2wav_opts = RIPPER_OPTS.value
431 #print cdda2wav_opts, cdda2wav_args
433 thing = popen2.Popen4(cdda2wav_cmd+' '+cdda2wav_opts+' '+cdda2wav_args )
434 outfile = thing.fromchild
436 while True:
437 line = myreadline(outfile)
438 if line:
439 x = re.match('([\s0-9]+)%', line)
440 if x:
441 percent = int(x.group(1))
442 self.status_update(tracknum, 'rip', percent)
443 else:
444 break
445 if self.please_stop:
446 break
448 if self.please_stop:
449 os.kill(thing.pid, signal.SIGKILL)
451 code = thing.wait()
452 self.status_update(tracknum, 'rip', 100)
453 #print code
454 return code
457 def get_lame(self, tracknum, track, artist, genre, album, year):
458 '''Run lame to encode a wav file to mp3'''
459 try:
460 int_year = int(year)
461 except:
462 int_year = 1
464 lame_cmd = ENCODER.value
465 lame_opts = ENCODER_OPTS.value
466 lame_tags = '--ta "%s" --tt "%s" --tl "%s" --tg "%s" --tn %d --ty %d' % (
467 artist, track, album, genre, tracknum+1, int_year)
468 lame_args = '"%s" "%s"' % (strip_illegal(track)+'.wav', strip_illegal(track)+'.mp3')
470 #print lame_opts, lame_tags, lame_args
472 thing = popen2.Popen4(lame_cmd+' '+lame_opts+' '+lame_tags+' '+lame_args )
473 outfile = thing.fromchild
475 while True:
476 line = myreadline(outfile)
477 if line:
478 #print line
479 #for some reason getting this right for lame was a royal pain.
480 x = re.match(r"^[\s]+([0-9]+)/([0-9]+)", line)
481 if x:
482 percent = int(100 * (float(x.group(1)) / float(x.group(2))))
483 self.status_update(tracknum, 'enc', percent)
484 else:
485 break
486 if self.please_stop:
487 break
489 if self.please_stop:
490 os.kill(thing.pid, signal.SIGKILL)
492 code = thing.wait()
493 self.status_update(tracknum, 'enc', 100)
494 #print code
495 return code
498 def rip_n_encode(self, button=None):
499 '''Process all selected tracks (rip and encode)'''
500 try: os.chdir(os.path.expanduser('~'))
501 except: pass
502 try: os.mkdir(LIBRARY.value)
503 except: pass
504 try: os.chdir(LIBRARY.value)
505 except: pass
507 if self.count and self.artist and self.album:
508 try: os.mkdir(self.artist)
509 except: pass
511 try: os.mkdir(self.artist+'/'+self.album)
512 except: pass
514 try: os.chdir(self.artist+'/'+self.album)
515 except: pass
517 self.please_stop = False
519 #the queue to feed tracks from ripper to encoder
520 self.wavqueue = Queue.Queue(1000)
522 self.ripper_thd = self.runit(self.ripit)
523 self.encoder_thd = self.runit(self.encodeit)
526 def ripit(self):
527 '''Thread to rip all selected tracks'''
528 self.is_ripping = True
529 for i in range(self.count):
530 if self.please_stop:
531 break;
533 if self.store[i][COL_ENABLE]:
534 track = self.store[i][COL_TRACK]
535 #print i, track
536 status = self.get_cdda2wav(i, track)
537 if status <> 0:
538 print 'cdda2wav died %d' % status
539 self.status_update(i, 'rip_error', 0)
540 else:
541 #push this track on the queue for the encoder
542 self.wavqueue.put((track, i))
544 #push None object to tell encoder we're done
545 if self.wavqueue:
546 self.wavqueue.put((None, None))
548 self.is_ripping = False
551 def encodeit(self):
552 '''Thread to encode all tracks from the wavqueue'''
553 self.is_encoding = True
554 while True:
555 if self.please_stop:
556 break
557 (track, tracknum) = self.wavqueue.get(True)
558 if track == None:
559 break
560 status = self.get_lame(tracknum, track, self.artist, self.genre, self.album, self.year)
561 if status <> 0:
562 print 'lame died %d' % status
563 self.status_update(tracknum, 'enc_error', 0)
564 try: os.unlink(strip_illegal(track)+".wav")
565 except: pass
566 try: os.unlink(strip_illegal(track)+".inf")
567 except: pass
569 self.is_encoding = False
570 del self.wavqueue
573 def status_update(self, row, state, percent):
574 '''Callback from rip/encode threads to update display'''
575 g.threads_enter()
577 iter = self.store.get_iter((row,))
578 if not iter: return
580 if state == 'rip':
581 if percent < 100:
582 self.store.set_value(iter, COL_STATUS, _('Ripping')+': %d%%' % percent)
583 else:
584 self.store.set_value(iter, COL_STATUS, _('Ripping')+': '+_('done'))
586 if state == 'enc':
587 if percent < 100:
588 self.store.set_value(iter, COL_STATUS, _('Encoding')+': %d%%' % percent)
589 else:
590 self.store.set_value(iter, COL_STATUS, _('Encoding')+': '+_('done'))
592 if state == 'rip_error':
593 self.store.set_value(iter, COL_STATUS, _('Ripping')+': '+_('error'))
595 if state == 'enc_error':
596 self.store.set_value(iter, COL_STATUS, _('Encoding')+': '+_('error'))
598 g.threads_leave()
601 def activate(self, view, path, column):
602 '''Edit a track name'''
603 model, iter = self.view.get_selection().get_selected()
604 if iter:
605 track = model.get_value(iter, COL_TRACK)
606 dlg = g.Dialog(APP_NAME)
607 dlg.set_position(g.WIN_POS_NONE)
608 dlg.set_default_size(350, 100)
609 (a, b) = dlg.get_size()
610 (x, y) = self.get_position()
611 (dx, dy) = self.get_size()
612 dlg.move(x+dx/2-a/2, y+dy/2-b/2)
613 dlg.show()
615 entry = g.Entry()
616 entry.set_text(track)
617 dlg.set_position(g.WIN_POS_MOUSE)
618 entry.show()
619 entry.set_activates_default(True)
620 dlg.vbox.pack_start(entry)
622 dlg.add_button(g.STOCK_OK, g.RESPONSE_OK)
623 dlg.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
624 dlg.set_default_response(g.RESPONSE_OK)
625 response = dlg.run()
627 if response == g.RESPONSE_OK:
628 track = entry.get_text()
629 #print track
630 model.set_value(iter, COL_TRACK, track)
631 dlg.destroy()
634 def toggle_check(self, cell, rownum):
635 '''Toggle state for each song'''
636 row = self.store[rownum]
637 row[COL_ENABLE] = not row[COL_ENABLE]
638 self.store.row_changed(rownum, row.iter)
641 def set_selection(self, thing):
642 '''Get current selection'''
643 #model, iter = self.view.get_selection().get_selected()
644 #if iter:
645 # track = model.get_value(iter, COL_TRACK)
647 def show_options(self, button=None):
648 '''Show Options dialog'''
649 rox.edit_options()
651 def get_options(self):
652 '''Get changed Options'''
653 pass
655 def delete_event(self, ev, e1):
656 '''Bye-bye'''
657 self.close()
659 def close(self, button = None):
660 '''We're outta here!'''
661 self.destroy()