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 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 os
, sys
, signal
, re
, string
, socket
, time
, popen2
, threading
, Queue
24 from random
import Random
25 from threading
import *
28 from rox
import g
, i18n
, app_options
29 from rox
.options
import Option
31 import PyCDDB
, cd_logic
, CDROM
, genres
39 _
= rox
.i18n
.translation(os
.path
.join(rox
.app_dir
, 'Messages'))
41 #Who am I and how did I get here?
42 APP_NAME
= 'Ripper' #I could call it Mr. Giles, but that would be gay.
43 APP_PATH
= os
.path
.split(os
.path
.abspath(sys
.argv
[0]))[0]
46 #Options.xml processing
47 rox
.setup_app_options(APP_NAME
)
49 #assume that everyone puts their music in ~/Music
50 LIBRARY
= Option('library', os
.path
.expanduser('~')+'/MyMusic')
53 RIPPER
= Option('ripper', 'cdda2wav')
54 RIPPER_DEV
= Option('ripper_dev', '/dev/cdrom')
55 RIPPER_LUN
= Option('ripper_lun', 'ATAPI:0,1,0')
56 RIPPER_OPTS
= Option('ripper_opts', '-x -H')
58 EJECT_AFTER_RIP
= Option('eject_after_rip', '0')
61 ENCODER
= Option('encoder', 'MP3')
63 MP3_ENCODER
= Option('mp3_encoder', 'lame')
64 MP3_ENCODER_OPTS
= Option('mp3_encoder_opts', '--vbr-new -b160 --nohist --add-id3v2')
66 OGG_ENCODER
= Option('ogg_encoder', 'oggenc')
67 OGG_ENCODER_OPTS
= Option('ogg_encoder_opts', '-q5')
69 #CDDB Server and Options
70 CDDB_SERVER
= Option('cddb_server', 'http://freedb.freedb.org/~cddb/cddb.cgi')
72 rox
.app_options
.notify()
82 # My gentoo python doesn't have universal line ending support compiled in
83 # and these guys (cdda2wav and lame) use CR line endings to pretty-up their output.
85 '''Return a line of input using \r or \n as terminators'''
87 while '\n' not in line
and '\r' not in line
:
89 if char
== '': return line
95 '''Return the full path of an executable if found on the path'''
96 if (filename
== None) or (filename
== ''):
99 env_path
= os
.getenv('PATH').split(':')
101 if os
.access(p
+'/'+filename
, os
.X_OK
):
102 return p
+'/'+filename
106 def strip_illegal(instr
):
107 '''remove illegal (filename) characters from string'''
110 str = string
.translate(str, string
.maketrans(r
'/+{}*.?', r
'--()___'))
114 class Ripper(rox
.Window
):
115 '''Rip and Encode a CD'''
117 rox
.Window
.__init
__(self
)
119 self
.set_title(APP_NAME
)
120 self
.set_default_size(450, 500)
121 self
.set_position(g
.WIN_POS_MOUSE
)
123 #capture wm delete event
124 self
.connect("delete_event", self
.delete_event
)
126 # Update things when options change
127 rox
.app_options
.add_notify(self
.get_options
)
131 #######################################
132 swin
= g
.ScrolledWindow()
133 self
.scroll_window
= swin
134 swin
.set_policy(g
.POLICY_AUTOMATIC
, g
.POLICY_AUTOMATIC
)
135 swin
.set_shadow_type(g
.SHADOW_IN
)
137 self
.store
= g
.ListStore(int, str, str, str)
138 view
= g
.TreeView(self
.store
)
141 view
.set_rules_hint(True)
143 # self.view.add_events(g.gdk.BUTTON_PRESS_MASK)
144 # self.view.connect('button-press-event', self.button_press)
146 cell
= g
.CellRendererToggle()
147 cell
.connect('toggled', self
.toggle_check
)
148 column
= g
.TreeViewColumn('', cell
, active
=COL_ENABLE
)
149 view
.append_column(column
)
150 column
.set_resizable(False)
151 column
.set_reorderable(False)
153 cell
= g
.CellRendererText()
154 column
= g
.TreeViewColumn(_('Track'), cell
, text
= COL_TRACK
)
155 view
.append_column(column
)
156 column
.set_resizable(True)
157 #column.set_sizing(g.TREE_VIEW_COLUMN_AUTOSIZE)
158 column
.set_reorderable(False)
160 cell
= g
.CellRendererText()
161 column
= g
.TreeViewColumn(_('Time'), cell
, text
= COL_TIME
)
162 view
.append_column(column
)
163 column
.set_resizable(True)
164 column
.set_reorderable(False)
166 cell
= g
.CellRendererText()
167 column
= g
.TreeViewColumn(_('Status'), cell
, text
= COL_STATUS
)
168 view
.append_column(column
)
169 column
.set_resizable(True)
170 column
.set_reorderable(False)
172 view
.connect('row-activated', self
.activate
)
173 self
.selection
= view
.get_selection()
174 self
.handler
= self
.selection
.connect('changed', self
.set_selection
)
177 self
.toolbar
= g
.Toolbar()
178 self
.toolbar
.set_style(g
.TOOLBAR_ICONS
)
179 self
.toolbar
.insert_stock(g
.STOCK_PREFERENCES
,
180 _('Settings'), None, self
.show_options
, None, 0)
181 self
.stop_btn
= self
.toolbar
.insert_stock(g
.STOCK_STOP
,
182 _('Stop'), None, self
.stop
, None, 0)
183 self
.rip_btn
= self
.toolbar
.insert_stock(g
.STOCK_EXECUTE
,
184 _('Rip & Encode'), None, self
.rip_n_encode
, None, 0)
185 self
.refresh_btn
= self
.toolbar
.insert_stock(g
.STOCK_REFRESH
,
186 _('Reload CD'), None, self
.do_get_tracks
, None, 0)
189 self
.table
= g
.Table(5, 2, False)
193 self
.artist_entry
= g
.Entry(max=255)
194 self
.artist_entry
.connect('changed', self
.stuff_changed
)
195 self
.table
.attach(g
.Label(str=_('Artist')), 0, 1, 2, 3, 0, 0, 4, y_pad
)
196 self
.table
.attach(self
.artist_entry
, 1, 2, 2, 3, g
.EXPAND|g
.FILL
, 0, x_pad
, y_pad
)
198 self
.album_entry
= g
.Entry(max=255)
199 self
.album_entry
.connect('changed', self
.stuff_changed
)
200 self
.table
.attach(g
.Label(str=_('Album')), 0, 1, 3, 4, 0, 0, 4, y_pad
)
201 self
.table
.attach(self
.album_entry
, 1, 2, 3, 4, g
.EXPAND|g
.FILL
, 0, x_pad
, y_pad
)
203 genres
.genre_list
.sort()
204 self
.genre_combo
= g
.Combo()
205 self
.genre_combo
.set_popdown_strings(genres
.genre_list
)
206 self
.genre_combo
.entry
.connect('changed', self
.stuff_changed
)
207 self
.table
.attach(g
.Label(str=_('Genre')), 0, 1, 4, 5, 0, 0, 4, y_pad
)
208 self
.table
.attach(self
.genre_combo
, 1, 2, 4, 5, g
.EXPAND|g
.FILL
, 0, x_pad
, y_pad
)
210 self
.year_entry
= g
.Entry(max=4)
211 self
.year_entry
.connect('changed', self
.stuff_changed
)
212 self
.table
.attach(g
.Label(str=_('Year')), 0, 1, 5, 6, 0, 0, 4, y_pad
)
213 self
.table
.attach(self
.year_entry
, 1, 2, 5, 6, g
.EXPAND|g
.FILL
, 0, x_pad
, y_pad
)
216 # Create layout, pack and show widgets
219 self
.vbox
.pack_start(self
.toolbar
, False, True, 0)
220 self
.vbox
.pack_start(self
.table
, False, True, 0)
221 self
.vbox
.pack_start(self
.scroll_window
, True, True, 0)
225 self
.ripper_thd
= None
226 self
.encoder_thd
= None
227 self
.is_ripping
= False
228 self
.is_encoding
= False
229 self
.is_cddbing
= False
230 self
.stop_request
= False
232 cd_logic
.set_dev(RIPPER_DEV
.value
)
233 self
.cd_status
= cd_logic
.check_dev()
235 self
.cd_status_changed
= False
237 if self
.cd_status
in [CDROM
.CDS_TRAY_OPEN
, CDROM
.CDS_NO_DISC
]:
241 g
.timeout_add(1000, self
.update_gui
)
244 def update_gui(self
):
245 '''Update button status based on current state'''
246 cd_status
= cd_logic
.check_dev()
247 if self
.cd_status
!= cd_status
:
248 self
.cd_status
= cd_status
249 self
.cd_status_changed
= True
251 if self
.is_ripping
or self
.is_encoding
:
252 self
.stop_btn
.set_sensitive(True)
253 self
.rip_btn
.set_sensitive(False)
254 self
.refresh_btn
.set_sensitive(False)
256 if not self
.is_ripping
and not self
.is_encoding
and not self
.is_cddbing
:
257 self
.stop_btn
.set_sensitive(False)
258 self
.rip_btn
.set_sensitive(True)
259 self
.refresh_btn
.set_sensitive(True)
261 #get tracks if cd changed and not doing other things
262 if self
.cd_status_changed
:
263 if self
.cd_status
in [CDROM
.CDS_TRAY_OPEN
, CDROM
.CDS_NO_DISC
]:
267 disc_id
= cd_logic
.get_disc_id()
268 if self
.disc_id
<> disc_id
:
269 self
.disc_id
= disc_id
271 self
.cd_status_changed
= False
273 #need this to keep the timer running(?)
277 def stuff_changed(self
, button
=None):
278 '''Get new text from edit boxes and save it'''
279 self
.genre
= self
.genre_combo
.entry
.get_text()
280 self
.artist
= self
.artist_entry
.get_text()
281 self
.album
= self
.album_entry
.get_text()
282 self
.year
= self
.year_entry
.get_text()
286 '''Run a function in a thread'''
287 thd_it
= Thread(name
='mythread', target
=it
)
288 thd_it
.setDaemon(True)
294 '''Stop current rip/encode process'''
295 self
.stop_request
= True
298 def do_get_tracks(self
, button
=None):
299 '''Get the track info (cddb and cd) in a thread'''
302 self
.cddb_thd
= self
.runit(self
.get_tracks
)
306 '''Clear all info and display <no disc>'''
307 #print "no disc in tray?"
310 self
.artist_entry
.set_text(_('<no disc>'))
311 self
.album_entry
.set_text('')
312 self
.genre_combo
.entry
.set_text('')
313 self
.year_entry
.set_text('')
314 self
.view
.columns_autosize()
318 def get_tracks(self
):
319 '''Get the track info (cddb and cd)'''
320 self
.is_cddbing
= True
321 stuff
= self
.get_cddb()
323 (count
, artist
, album
, genre
, year
, tracklist
) = stuff
324 #print count, artist, album, genre, year, tracklist
331 self
.tracklist
= tracklist
333 if artist
: self
.artist_entry
.set_text(artist
)
334 if album
: self
.album_entry
.set_text(album
)
335 if genre
: self
.genre_combo
.entry
.set_text(genre
)
336 if year
: self
.year_entry
.set_text(year
)
339 for track
in tracklist
:
341 iter = self
.store
.append(None)
342 self
.store
.set(iter, COL_TRACK
, track
[0])
343 self
.store
.set(iter, COL_TIME
, track
[1])
344 self
.store
.set(iter, COL_ENABLE
, True)
346 self
.view
.columns_autosize()
348 self
.is_cddbing
= False
352 '''Query cddb for track and cd info'''
354 dlg
= g
.MessageDialog(buttons
=g
.BUTTONS_CANCEL
, message_format
="Getting Track Info.")
355 dlg
.set_position(g
.WIN_POS_NONE
)
356 (a
, b
) = dlg
.get_size()
357 (x
, y
) = self
.get_position()
358 (dx
, dy
) = self
.get_size()
359 dlg
.move(x
+dx
/2-a
/2, y
+dy
/2-b
/2)
363 count
= artist
= genre
= album
= year
= ''
367 #Note: all the nested try statements are to ensure that as much
368 #info is processed as possible. One exception should not stop
369 #the whole thing and return nothing.
372 count
= cd_logic
.total_tracks()
373 cddb_id
= cd_logic
.get_cddb_id()
375 #PyCDDB wants a string delimited by spaces, go figure.
378 cddb_id_string
+= str(n
)+' '
380 #print cddb_id, cddb_id_string
382 for i
in range(count
):
383 tracktime
= cd_logic
.get_track_time_total(i
+1)
384 track_time
= time
.strftime('%M:%S', time
.gmtime(tracktime
))
385 tracklist
.append((_('Track')+`i`
,track_time
))
388 db
= PyCDDB
.PyCDDB(CDDB_SERVER
.value
)
389 query_info
= db
.query(cddb_id_string
)
393 dlg
.set_title(_('Got Disc Info'))
396 #make sure we didn't get an error, then query CDDB
397 if len(query_info
) > 0:
399 index
= rndm
.randrange(0, len(query_info
))
400 read_info
= db
.read(query_info
[index
])
403 dlg
.set_title(_('Got Track Info'))
407 (artist
, album
) = query_info
[index
]['title'].split('/')
408 artist
= artist
.strip()
409 album
= album
.strip()
410 genre
= query_info
[index
]['category']
411 if genre
in ['misc', 'data']:
414 print query_info
['year']
415 print read_info
['EXTD']
416 print read_info
['YEARD']
418 #x = re.match(r'.*YEAR: (.+).*',read_info['EXTD'])
425 if len(read_info
['TTITLE']) > 0:
426 for i
in range(count
):
428 track_name
= read_info
['TTITLE'][i
]
429 track_time
= tracklist
[i
][1]
430 #print i, track_name, track_time
431 tracklist
[i
] = (track_name
, track_time
)
443 return count
, artist
, album
, genre
, year
, tracklist
446 def get_cdda2wav(self
, tracknum
, track
):
447 '''Run cdda2wav to rip a track from the CD'''
448 cdda2wav_cmd
= RIPPER
.value
449 cdda2wav_dev
= RIPPER_DEV
.value
450 cdda2wav_lun
= RIPPER_LUN
.value
451 cdda2wav_args
= '-D%s -A%s -t %d "%s"' % (
452 cdda2wav_lun
, cdda2wav_dev
, tracknum
+1, strip_illegal(track
))
453 cdda2wav_opts
= RIPPER_OPTS
.value
454 #print cdda2wav_opts, cdda2wav_args
456 thing
= popen2
.Popen4(cdda2wav_cmd
+' '+cdda2wav_opts
+' '+cdda2wav_args
)
457 outfile
= thing
.fromchild
460 line
= myreadline(outfile
)
462 x
= re
.match('([\s0-9]+)%', line
)
464 percent
= int(x
.group(1))
465 self
.status_update(tracknum
, 'rip', percent
)
468 if self
.stop_request
:
471 if self
.stop_request
:
472 os
.kill(thing
.pid
, signal
.SIGKILL
)
475 self
.status_update(tracknum
, 'rip', 100)
480 def get_lame(self
, tracknum
, track
, artist
, genre
, album
, year
):
481 '''Run lame to encode a wav file to mp3'''
487 lame_cmd
= MP3_ENCODER
.value
488 lame_opts
= MP3_ENCODER_OPTS
.value
489 lame_tags
= '--ta "%s" --tt "%s" --tl "%s" --tg "%s" --tn %d --ty %d' % (
490 artist
, track
, album
, genre
, tracknum
+1, int_year
)
491 lame_args
= '"%s" "%s"' % (strip_illegal(track
)+'.wav', strip_illegal(track
)+'.mp3')
493 #print lame_opts, lame_tags, lame_args
495 thing
= popen2
.Popen4(lame_cmd
+' '+lame_opts
+' '+lame_tags
+' '+lame_args
)
496 outfile
= thing
.fromchild
499 line
= myreadline(outfile
)
502 #for some reason getting this right for lame was a royal pain.
503 x
= re
.match(r
"^[\s]+([0-9]+)/([0-9]+)", line
)
505 percent
= int(100 * (float(x
.group(1)) / float(x
.group(2))))
506 self
.status_update(tracknum
, 'enc', percent
)
509 if self
.stop_request
:
512 if self
.stop_request
:
513 os
.kill(thing
.pid
, signal
.SIGKILL
)
516 filename
= strip_illegal(track
)+'.mp3'
517 xattr
.setxattr(filename
, 'user.Title', track
)
518 xattr
.setxattr(filename
, 'user.Artist', artist
)
519 xattr
.setxattr(filename
, 'user.Album', album
)
520 xattr
.setxattr(filename
, 'user.Genre', genre
)
521 xattr
.setxattr(filename
, 'user.Track', '%d' % tracknum
)
522 xattr
.setxattr(filename
, 'user.Year', year
)
527 self
.status_update(tracknum
, 'enc', 100)
532 def get_ogg(self
, tracknum
, track
, artist
, genre
, album
, year
):
533 '''Run oggenc to encode a wav file to ogg'''
539 ogg_cmd
= OGG_ENCODER
.value
540 ogg_opts
= OGG_ENCODER_OPTS
.value
541 ogg_tags
= '-a "%s" -t "%s" -l "%s" -G "%s" -N %d -d %d' % (
542 artist
, track
, album
, genre
, tracknum
+1, int_year
)
543 ogg_args
= '"%s"' % (strip_illegal(track
)+'.wav')
545 #print ogg_opts, ogg_tags, ogg_args
547 thing
= popen2
.Popen4(ogg_cmd
+' '+ogg_opts
+' '+ogg_tags
+' '+ogg_args
)
548 outfile
= thing
.fromchild
551 line
= myreadline(outfile
)
554 #for some reason getting this right for ogg was a royal pain.
555 x
= re
.match('^.*\[[\s]*([.0-9]+)%\]', line
)
557 percent
= float(x
.group(1))
558 self
.status_update(tracknum
, 'enc', percent
)
561 if self
.stop_request
:
564 if self
.stop_request
:
565 os
.kill(thing
.pid
, signal
.SIGKILL
)
568 filename
= strip_illegal(track
)+'.ogg'
569 xattr
.setxattr(filename
, 'user.Title', track
)
570 xattr
.setxattr(filename
, 'user.Artist', artist
)
571 xattr
.setxattr(filename
, 'user.Album', album
)
572 xattr
.setxattr(filename
, 'user.Genre', genre
)
573 xattr
.setxattr(filename
, 'user.Track', '%d' % tracknum
)
574 xattr
.setxattr(filename
, 'user.Year', year
)
579 self
.status_update(tracknum
, 'enc', 100)
584 def rip_n_encode(self
, button
=None):
585 '''Process all selected tracks (rip and encode)'''
586 try: os
.chdir(os
.path
.expanduser('~'))
588 try: os
.mkdir(LIBRARY
.value
)
590 try: os
.chdir(LIBRARY
.value
)
593 if self
.count
and self
.artist
and self
.album
:
594 try: os
.mkdir(self
.artist
)
597 try: os
.mkdir(self
.artist
+'/'+self
.album
)
600 try: os
.chdir(self
.artist
+'/'+self
.album
)
603 self
.stop_request
= False
605 #the queue to feed tracks from ripper to encoder
606 self
.wavqueue
= Queue
.Queue(1000)
608 self
.ripper_thd
= self
.runit(self
.ripit
)
609 self
.encoder_thd
= self
.runit(self
.encodeit
)
613 '''Thread to rip all selected tracks'''
614 self
.is_ripping
= True
615 for i
in range(self
.count
):
616 if self
.stop_request
:
619 if self
.store
[i
][COL_ENABLE
]:
620 track
= self
.store
[i
][COL_TRACK
]
622 status
= self
.get_cdda2wav(i
, track
)
624 print 'cdda2wav died %d' % status
625 self
.status_update(i
, 'rip_error', 0)
627 #push this track on the queue for the encoder
629 self
.wavqueue
.put((track
, i
))
631 #push None object to tell encoder we're done
633 self
.wavqueue
.put((None, None))
635 self
.is_ripping
= False
637 if EJECT_AFTER_RIP
.int_value
:
642 '''Thread to encode all tracks from the wavqueue'''
643 self
.is_encoding
= True
645 if self
.stop_request
:
647 (track
, tracknum
) = self
.wavqueue
.get(True)
651 if ENCODER
.value
== 'MP3':
652 status
= self
.get_lame(tracknum
, track
, self
.artist
, self
.genre
, self
.album
, self
.year
)
654 status
= self
.get_ogg(tracknum
, track
, self
.artist
, self
.genre
, self
.album
, self
.year
)
657 print 'encoder died %d' % status
658 self
.status_update(tracknum
, 'enc_error', 0)
659 try: os
.unlink(strip_illegal(track
)+".wav")
661 try: os
.unlink(strip_illegal(track
)+".inf")
664 self
.is_encoding
= False
668 def status_update(self
, row
, state
, percent
):
669 '''Callback from rip/encode threads to update display'''
672 iter = self
.store
.get_iter((row
,))
677 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': %d%%' % percent
)
679 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': '+_('done'))
683 self
.store
.set_value(iter, COL_STATUS
, _('Encoding')+': %d%%' % percent
)
685 self
.store
.set_value(iter, COL_STATUS
, _('Encoding')+': '+_('done'))
687 if state
== 'rip_error':
688 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': '+_('error'))
690 if state
== 'enc_error':
691 self
.store
.set_value(iter, COL_STATUS
, _('Encoding')+': '+_('error'))
696 def activate(self
, view
, path
, column
):
697 '''Edit a track name'''
698 model
, iter = self
.view
.get_selection().get_selected()
700 track
= model
.get_value(iter, COL_TRACK
)
701 dlg
= g
.Dialog(APP_NAME
)
702 dlg
.set_position(g
.WIN_POS_NONE
)
703 dlg
.set_default_size(350, 100)
704 (a
, b
) = dlg
.get_size()
705 (x
, y
) = self
.get_position()
706 (dx
, dy
) = self
.get_size()
707 dlg
.move(x
+dx
/2-a
/2, y
+dy
/2-b
/2)
711 entry
.set_text(track
)
712 dlg
.set_position(g
.WIN_POS_MOUSE
)
714 entry
.set_activates_default(True)
715 dlg
.vbox
.pack_start(entry
)
717 dlg
.add_button(g
.STOCK_OK
, g
.RESPONSE_OK
)
718 dlg
.add_button(g
.STOCK_CANCEL
, g
.RESPONSE_CANCEL
)
719 dlg
.set_default_response(g
.RESPONSE_OK
)
722 if response
== g
.RESPONSE_OK
:
723 track
= entry
.get_text()
725 model
.set_value(iter, COL_TRACK
, track
)
726 self
.view
.columns_autosize()
731 def toggle_check(self
, cell
, rownum
):
732 '''Toggle state for each song'''
733 row
= self
.store
[rownum
]
734 row
[COL_ENABLE
] = not row
[COL_ENABLE
]
735 self
.store
.row_changed(rownum
, row
.iter)
738 def set_selection(self
, thing
):
739 '''Get current selection'''
740 #model, iter = self.view.get_selection().get_selected()
742 # track = model.get_value(iter, COL_TRACK)
744 def show_options(self
, button
=None):
745 '''Show Options dialog'''
748 def get_options(self
):
749 '''Get changed Options'''
752 def delete_event(self
, ev
, e1
):
756 def close(self
, button
= None):
757 '''We're outta here!'''