3 GUI front-end to cdda2wav and lame.
5 Copyright 2004 Kenneth Hayber <khayber@socal.rr.com>
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
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')
59 rox
.app_options
.notify()
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.
72 '''Return a line of input using \r or \n as terminators'''
74 while '\n' not in line
and '\r' not in line
:
76 if char
== '': return line
82 '''Return the full path of an executable if found on the path'''
83 if (filename
== None) or (filename
== ''):
86 env_path
= os
.getenv('PATH').split(':')
88 if os
.access(p
+'/'+filename
, os
.X_OK
):
93 def strip_illegal(instr
):
94 '''remove illegal (filename) characters from string'''
97 str = string
.translate(str, string
.maketrans(r
'/+{}*.?', r
'--()___'))
101 class Ripper(rox
.Window
):
102 '''Rip and Encode a CD'''
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
)
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
)
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
)
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
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)
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()
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
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
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()
263 '''Run a function in a thread'''
264 thd_it
= Thread(name
='mythread', target
=it
)
265 thd_it
.setDaemon(True)
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'''
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()
286 #print "no disc in tray?"
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('')
296 (count
, artist
, album
, genre
, year
, tracklist
) = stuff
297 #print count, artist, album, genre, year, tracklist
304 self
.tracklist
= tracklist
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
:
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)
324 '''Query cddb for track and cd info'''
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)
335 count
= artist
= genre
= album
= year
= None
340 disc_id
= cd_logic
.get_disc_id()
341 self
.disc_id
= disc_id
345 dlg
.set_title('Got Disc ID')
348 count
= cd_logic
.total_tracks()
349 cddb_id
= cd_logic
.get_cddb_id()
351 #PyCDDB wants a string delimited by spaces, go figure.
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
))
363 db
= PyCDDB
.PyCDDB(CDDB_SERVER
.value
)
364 query_info
= db
.query(cddb_id_string
)
368 dlg
.set_title('Got Disc Info')
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
])
377 dlg
.set_title('Got Track Info')
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']:
388 print query_info
['year']
389 print read_info
['EXTD']
390 print read_info
['YEARD']
392 #x = re.match(r'.*YEAR: (.+).*',read_info['EXTD'])
399 if len(read_info
['TTITLE']) > 0:
400 for i
in range(count
):
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
)
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
437 line
= myreadline(outfile
)
439 x
= re
.match('([\s0-9]+)%', line
)
441 percent
= int(x
.group(1))
442 self
.status_update(tracknum
, 'rip', percent
)
449 os
.kill(thing
.pid
, signal
.SIGKILL
)
452 self
.status_update(tracknum
, 'rip', 100)
457 def get_lame(self
, tracknum
, track
, artist
, genre
, album
, year
):
458 '''Run lame to encode a wav file to mp3'''
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
476 line
= myreadline(outfile
)
479 #for some reason getting this right for lame was a royal pain.
480 x
= re
.match(r
"^[\s]+([0-9]+)/([0-9]+)", line
)
482 percent
= int(100 * (float(x
.group(1)) / float(x
.group(2))))
483 self
.status_update(tracknum
, 'enc', percent
)
490 os
.kill(thing
.pid
, signal
.SIGKILL
)
493 self
.status_update(tracknum
, 'enc', 100)
498 def rip_n_encode(self
, button
=None):
499 '''Process all selected tracks (rip and encode)'''
500 try: os
.chdir(os
.path
.expanduser('~'))
502 try: os
.mkdir(LIBRARY
.value
)
504 try: os
.chdir(LIBRARY
.value
)
507 if self
.count
and self
.artist
and self
.album
:
508 try: os
.mkdir(self
.artist
)
511 try: os
.mkdir(self
.artist
+'/'+self
.album
)
514 try: os
.chdir(self
.artist
+'/'+self
.album
)
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
)
527 '''Thread to rip all selected tracks'''
528 self
.is_ripping
= True
529 for i
in range(self
.count
):
533 if self
.store
[i
][COL_ENABLE
]:
534 track
= self
.store
[i
][COL_TRACK
]
536 status
= self
.get_cdda2wav(i
, track
)
538 print 'cdda2wav died %d' % status
539 self
.status_update(i
, 'rip_error', 0)
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
546 self
.wavqueue
.put((None, None))
548 self
.is_ripping
= False
552 '''Thread to encode all tracks from the wavqueue'''
553 self
.is_encoding
= True
557 (track
, tracknum
) = self
.wavqueue
.get(True)
560 status
= self
.get_lame(tracknum
, track
, self
.artist
, self
.genre
, self
.album
, self
.year
)
562 print 'lame died %d' % status
563 self
.status_update(tracknum
, 'enc_error', 0)
564 try: os
.unlink(strip_illegal(track
)+".wav")
566 try: os
.unlink(strip_illegal(track
)+".inf")
569 self
.is_encoding
= False
573 def status_update(self
, row
, state
, percent
):
574 '''Callback from rip/encode threads to update display'''
577 iter = self
.store
.get_iter((row
,))
582 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': %d%%' % percent
)
584 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': '+_('done'))
588 self
.store
.set_value(iter, COL_STATUS
, _('Encoding')+': %d%%' % percent
)
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'))
601 def activate(self
, view
, path
, column
):
602 '''Edit a track name'''
603 model
, iter = self
.view
.get_selection().get_selected()
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)
616 entry
.set_text(track
)
617 dlg
.set_position(g
.WIN_POS_MOUSE
)
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
)
627 if response
== g
.RESPONSE_OK
:
628 track
= entry
.get_text()
630 model
.set_value(iter, COL_TRACK
, track
)
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()
645 # track = model.get_value(iter, COL_TRACK)
647 def show_options(self
, button
=None):
648 '''Show Options dialog'''
651 def get_options(self
):
652 '''Get changed Options'''
655 def delete_event(self
, ev
, e1
):
659 def close(self
, button
= None):
660 '''We're outta here!'''