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
, Menu
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 cell
= g
.CellRendererToggle()
144 cell
.connect('toggled', self
.toggle_check
)
145 column
= g
.TreeViewColumn('', cell
, active
=COL_ENABLE
)
146 view
.append_column(column
)
147 column
.set_resizable(False)
148 column
.set_reorderable(False)
150 cell
= g
.CellRendererText()
151 column
= g
.TreeViewColumn(_('Track'), cell
, text
= COL_TRACK
)
152 view
.append_column(column
)
153 column
.set_resizable(True)
154 #column.set_sizing(g.TREE_VIEW_COLUMN_AUTOSIZE)
155 column
.set_reorderable(False)
157 cell
= g
.CellRendererText()
158 column
= g
.TreeViewColumn(_('Time'), cell
, text
= COL_TIME
)
159 view
.append_column(column
)
160 column
.set_resizable(True)
161 column
.set_reorderable(False)
163 cell
= g
.CellRendererText()
164 column
= g
.TreeViewColumn(_('Status'), cell
, text
= COL_STATUS
)
165 view
.append_column(column
)
166 column
.set_resizable(True)
167 column
.set_reorderable(False)
169 view
.connect('row-activated', self
.activate
)
170 self
.selection
= view
.get_selection()
171 self
.handler
= self
.selection
.connect('changed', self
.set_selection
)
174 self
.toolbar
= g
.Toolbar()
175 self
.toolbar
.set_style(g
.TOOLBAR_ICONS
)
176 self
.toolbar
.insert_stock(g
.STOCK_PREFERENCES
,
177 _('Settings'), None, self
.show_options
, None, 0)
178 self
.stop_btn
= self
.toolbar
.insert_stock(g
.STOCK_STOP
,
179 _('Stop'), None, self
.stop
, None, 0)
180 self
.rip_btn
= self
.toolbar
.insert_stock(g
.STOCK_EXECUTE
,
181 _('Rip & Encode'), None, self
.rip_n_encode
, None, 0)
182 self
.refresh_btn
= self
.toolbar
.insert_stock(g
.STOCK_REFRESH
,
183 _('Reload CD'), None, self
.do_get_tracks
, None, 0)
186 self
.table
= g
.Table(5, 2, False)
190 self
.artist_entry
= g
.Entry(max=255)
191 self
.artist_entry
.connect('changed', self
.stuff_changed
)
192 self
.table
.attach(g
.Label(str=_('Artist')), 0, 1, 2, 3, 0, 0, 4, y_pad
)
193 self
.table
.attach(self
.artist_entry
, 1, 2, 2, 3, g
.EXPAND|g
.FILL
, 0, x_pad
, y_pad
)
195 self
.album_entry
= g
.Entry(max=255)
196 self
.album_entry
.connect('changed', self
.stuff_changed
)
197 self
.table
.attach(g
.Label(str=_('Album')), 0, 1, 3, 4, 0, 0, 4, y_pad
)
198 self
.table
.attach(self
.album_entry
, 1, 2, 3, 4, g
.EXPAND|g
.FILL
, 0, x_pad
, y_pad
)
200 genres
.genre_list
.sort()
201 self
.genre_combo
= g
.Combo()
202 self
.genre_combo
.set_popdown_strings(genres
.genre_list
)
203 self
.genre_combo
.entry
.connect('changed', self
.stuff_changed
)
204 self
.table
.attach(g
.Label(str=_('Genre')), 0, 1, 4, 5, 0, 0, 4, y_pad
)
205 self
.table
.attach(self
.genre_combo
, 1, 2, 4, 5, g
.EXPAND|g
.FILL
, 0, x_pad
, y_pad
)
207 self
.year_entry
= g
.Entry(max=4)
208 self
.year_entry
.connect('changed', self
.stuff_changed
)
209 self
.table
.attach(g
.Label(str=_('Year')), 0, 1, 5, 6, 0, 0, 4, y_pad
)
210 self
.table
.attach(self
.year_entry
, 1, 2, 5, 6, g
.EXPAND|g
.FILL
, 0, x_pad
, y_pad
)
213 # Create layout, pack and show widgets
216 self
.vbox
.pack_start(self
.toolbar
, False, True, 0)
217 self
.vbox
.pack_start(self
.table
, False, True, 0)
218 self
.vbox
.pack_start(self
.scroll_window
, True, True, 0)
222 self
.add_events(g
.gdk
.BUTTON_PRESS_MASK
)
223 self
.connect('button-press-event', self
.button_press
)
224 view
.add_events(g
.gdk
.BUTTON_PRESS_MASK
)
225 view
.connect('button-press-event', self
.button_press
)
227 Menu
.set_save_name(APP_NAME
)
228 self
.menu
= Menu
.Menu('main', [
229 Menu
.Action(_('Rip & Encode'), 'rip_n_encode', '', g
.STOCK_EXECUTE
),
230 Menu
.Action(_('Reload CD'), 'do_get_tracks', '', g
.STOCK_REFRESH
),
231 Menu
.Action(_('Stop'), 'stop', '', g
.STOCK_STOP
),
233 Menu
.Action(_('Settings'), 'show_options', '', g
.STOCK_PREFERENCES
),
234 Menu
.Action(_("Quit"), 'close', '', g
.STOCK_CLOSE
),
236 self
.menu
.attach(self
,self
)
240 self
.ripper_thd
= None
241 self
.encoder_thd
= None
242 self
.is_ripping
= False
243 self
.is_encoding
= False
244 self
.is_cddbing
= False
245 self
.stop_request
= False
247 cd_logic
.set_dev(RIPPER_DEV
.value
)
248 self
.cd_status
= cd_logic
.check_dev()
250 self
.cd_status_changed
= False
252 if self
.cd_status
in [CDROM
.CDS_TRAY_OPEN
, CDROM
.CDS_NO_DISC
]:
256 g
.timeout_add(1000, self
.update_gui
)
259 def update_gui(self
):
260 '''Update button status based on current state'''
261 cd_status
= cd_logic
.check_dev()
262 if self
.cd_status
!= cd_status
:
263 self
.cd_status
= cd_status
264 self
.cd_status_changed
= True
266 if self
.is_ripping
or self
.is_encoding
:
267 self
.stop_btn
.set_sensitive(True)
268 self
.rip_btn
.set_sensitive(False)
269 self
.refresh_btn
.set_sensitive(False)
271 if not self
.is_ripping
and not self
.is_encoding
and not self
.is_cddbing
:
272 self
.stop_btn
.set_sensitive(False)
273 self
.rip_btn
.set_sensitive(True)
274 self
.refresh_btn
.set_sensitive(True)
276 #get tracks if cd changed and not doing other things
277 if self
.cd_status_changed
:
278 if self
.cd_status
in [CDROM
.CDS_TRAY_OPEN
, CDROM
.CDS_NO_DISC
]:
282 disc_id
= cd_logic
.get_disc_id()
283 if self
.disc_id
<> disc_id
:
284 self
.disc_id
= disc_id
286 self
.cd_status_changed
= False
288 #need this to keep the timer running(?)
292 def stuff_changed(self
, button
=None):
293 '''Get new text from edit boxes and save it'''
294 self
.genre
= self
.genre_combo
.entry
.get_text()
295 self
.artist
= self
.artist_entry
.get_text()
296 self
.album
= self
.album_entry
.get_text()
297 self
.year
= self
.year_entry
.get_text()
301 '''Run a function in a thread'''
302 thd_it
= Thread(name
='mythread', target
=it
)
303 thd_it
.setDaemon(True)
309 '''Stop current rip/encode process'''
310 self
.stop_request
= True
313 def do_get_tracks(self
, button
=None):
314 '''Get the track info (cddb and cd) in a thread'''
317 self
.cddb_thd
= self
.runit(self
.get_tracks
)
321 '''Clear all info and display <no disc>'''
322 #print "no disc in tray?"
325 self
.artist_entry
.set_text(_('<no disc>'))
326 self
.album_entry
.set_text('')
327 self
.genre_combo
.entry
.set_text('')
328 self
.year_entry
.set_text('')
329 self
.view
.columns_autosize()
333 def get_tracks(self
):
334 '''Get the track info (cddb and cd)'''
335 self
.is_cddbing
= True
336 stuff
= self
.get_cddb()
338 (count
, artist
, album
, genre
, year
, tracklist
) = stuff
339 #print count, artist, album, genre, year, tracklist
346 self
.tracklist
= tracklist
348 if artist
: self
.artist_entry
.set_text(artist
)
349 if album
: self
.album_entry
.set_text(album
)
350 if genre
: self
.genre_combo
.entry
.set_text(genre
)
351 if year
: self
.year_entry
.set_text(year
)
354 for track
in tracklist
:
356 iter = self
.store
.append(None)
357 self
.store
.set(iter, COL_TRACK
, track
[0])
358 self
.store
.set(iter, COL_TIME
, track
[1])
359 self
.store
.set(iter, COL_ENABLE
, True)
361 self
.view
.columns_autosize()
363 self
.is_cddbing
= False
367 '''Query cddb for track and cd info'''
369 dlg
= g
.MessageDialog(buttons
=g
.BUTTONS_CANCEL
, message_format
="Getting Track Info.")
370 dlg
.set_position(g
.WIN_POS_NONE
)
371 (a
, b
) = dlg
.get_size()
372 (x
, y
) = self
.get_position()
373 (dx
, dy
) = self
.get_size()
374 dlg
.move(x
+dx
/2-a
/2, y
+dy
/2-b
/2)
378 count
= artist
= genre
= album
= year
= ''
382 #Note: all the nested try statements are to ensure that as much
383 #info is processed as possible. One exception should not stop
384 #the whole thing and return nothing.
387 count
= cd_logic
.total_tracks()
388 cddb_id
= cd_logic
.get_cddb_id()
390 #PyCDDB wants a string delimited by spaces, go figure.
393 cddb_id_string
+= str(n
)+' '
395 #print 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
)
408 dlg
.set_title(_('Got Disc Info'))
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
])
418 dlg
.set_title(_('Got Track Info'))
422 (artist
, album
) = query_info
[index
]['title'].split('/')
423 artist
= artist
.strip()
424 album
= album
.strip()
425 genre
= query_info
[index
]['category']
426 if genre
in ['misc', 'data']:
429 print query_info
['year']
430 print read_info
['EXTD']
431 print 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
]
444 track_time
= tracklist
[i
][1]
445 #print i, track_name, track_time
446 tracklist
[i
] = (track_name
, track_time
)
458 return count
, artist
, album
, genre
, year
, tracklist
461 def get_cdda2wav(self
, tracknum
, track
):
462 '''Run cdda2wav to rip a track from the CD'''
463 cdda2wav_cmd
= RIPPER
.value
464 cdda2wav_dev
= RIPPER_DEV
.value
465 cdda2wav_lun
= RIPPER_LUN
.value
466 cdda2wav_args
= '-D%s -A%s -t %d "%s"' % (
467 cdda2wav_lun
, cdda2wav_dev
, tracknum
+1, strip_illegal(track
))
468 cdda2wav_opts
= RIPPER_OPTS
.value
469 #print cdda2wav_opts, cdda2wav_args
471 thing
= popen2
.Popen4(cdda2wav_cmd
+' '+cdda2wav_opts
+' '+cdda2wav_args
)
472 outfile
= thing
.fromchild
475 line
= myreadline(outfile
)
477 x
= re
.match('([\s0-9]+)%', line
)
479 percent
= int(x
.group(1))
480 self
.status_update(tracknum
, 'rip', percent
)
483 if self
.stop_request
:
486 if self
.stop_request
:
487 os
.kill(thing
.pid
, signal
.SIGKILL
)
490 self
.status_update(tracknum
, 'rip', 100)
495 def get_lame(self
, tracknum
, track
, artist
, genre
, album
, year
):
496 '''Run lame to encode a wav file to mp3'''
502 lame_cmd
= MP3_ENCODER
.value
503 lame_opts
= MP3_ENCODER_OPTS
.value
504 lame_tags
= '--ta "%s" --tt "%s" --tl "%s" --tg "%s" --tn %d --ty %d' % (
505 artist
, track
, album
, genre
, tracknum
+1, int_year
)
506 lame_args
= '"%s" "%s"' % (strip_illegal(track
)+'.wav', strip_illegal(track
)+'.mp3')
508 #print lame_opts, lame_tags, lame_args
510 thing
= popen2
.Popen4(lame_cmd
+' '+lame_opts
+' '+lame_tags
+' '+lame_args
)
511 outfile
= thing
.fromchild
514 line
= myreadline(outfile
)
517 #for some reason getting this right for lame was a royal pain.
518 x
= re
.match(r
"^[\s]+([0-9]+)/([0-9]+)", line
)
520 percent
= int(100 * (float(x
.group(1)) / float(x
.group(2))))
521 self
.status_update(tracknum
, 'enc', percent
)
524 if self
.stop_request
:
527 if self
.stop_request
:
528 os
.kill(thing
.pid
, signal
.SIGKILL
)
531 filename
= strip_illegal(track
)+'.mp3'
532 xattr
.setxattr(filename
, 'user.Title', track
)
533 xattr
.setxattr(filename
, 'user.Artist', artist
)
534 xattr
.setxattr(filename
, 'user.Album', album
)
535 xattr
.setxattr(filename
, 'user.Genre', genre
)
536 xattr
.setxattr(filename
, 'user.Track', '%d' % tracknum
)
537 xattr
.setxattr(filename
, 'user.Year', year
)
542 self
.status_update(tracknum
, 'enc', 100)
547 def get_ogg(self
, tracknum
, track
, artist
, genre
, album
, year
):
548 '''Run oggenc to encode a wav file to ogg'''
554 ogg_cmd
= OGG_ENCODER
.value
555 ogg_opts
= OGG_ENCODER_OPTS
.value
556 ogg_tags
= '-a "%s" -t "%s" -l "%s" -G "%s" -N %d -d %d' % (
557 artist
, track
, album
, genre
, tracknum
+1, int_year
)
558 ogg_args
= '"%s"' % (strip_illegal(track
)+'.wav')
560 #print ogg_opts, ogg_tags, ogg_args
562 thing
= popen2
.Popen4(ogg_cmd
+' '+ogg_opts
+' '+ogg_tags
+' '+ogg_args
)
563 outfile
= thing
.fromchild
566 line
= myreadline(outfile
)
569 #for some reason getting this right for ogg was a royal pain.
570 x
= re
.match('^.*\[[\s]*([.0-9]+)%\]', line
)
572 percent
= float(x
.group(1))
573 self
.status_update(tracknum
, 'enc', percent
)
576 if self
.stop_request
:
579 if self
.stop_request
:
580 os
.kill(thing
.pid
, signal
.SIGKILL
)
583 filename
= strip_illegal(track
)+'.ogg'
584 xattr
.setxattr(filename
, 'user.Title', track
)
585 xattr
.setxattr(filename
, 'user.Artist', artist
)
586 xattr
.setxattr(filename
, 'user.Album', album
)
587 xattr
.setxattr(filename
, 'user.Genre', genre
)
588 xattr
.setxattr(filename
, 'user.Track', '%d' % tracknum
)
589 xattr
.setxattr(filename
, 'user.Year', year
)
594 self
.status_update(tracknum
, 'enc', 100)
599 def rip_n_encode(self
, button
=None):
600 '''Process all selected tracks (rip and encode)'''
601 try: os
.chdir(os
.path
.expanduser('~'))
603 try: os
.mkdir(LIBRARY
.value
)
605 try: os
.chdir(LIBRARY
.value
)
608 if self
.count
and self
.artist
and self
.album
:
609 try: os
.mkdir(self
.artist
)
612 try: os
.mkdir(self
.artist
+'/'+self
.album
)
615 try: os
.chdir(self
.artist
+'/'+self
.album
)
618 self
.stop_request
= False
620 #the queue to feed tracks from ripper to encoder
621 self
.wavqueue
= Queue
.Queue(1000)
623 self
.ripper_thd
= self
.runit(self
.ripit
)
624 self
.encoder_thd
= self
.runit(self
.encodeit
)
628 '''Thread to rip all selected tracks'''
629 self
.is_ripping
= True
630 for i
in range(self
.count
):
631 if self
.stop_request
:
634 if self
.store
[i
][COL_ENABLE
]:
635 track
= self
.store
[i
][COL_TRACK
]
637 status
= self
.get_cdda2wav(i
, track
)
639 print 'cdda2wav died %d' % status
640 self
.status_update(i
, 'rip_error', 0)
642 #push this track on the queue for the encoder
644 self
.wavqueue
.put((track
, i
))
646 #push None object to tell encoder we're done
648 self
.wavqueue
.put((None, None))
650 self
.is_ripping
= False
652 if EJECT_AFTER_RIP
.int_value
:
657 '''Thread to encode all tracks from the wavqueue'''
658 self
.is_encoding
= True
660 if self
.stop_request
:
662 (track
, tracknum
) = self
.wavqueue
.get(True)
666 if ENCODER
.value
== 'MP3':
667 status
= self
.get_lame(tracknum
, track
, self
.artist
, self
.genre
, self
.album
, self
.year
)
669 status
= self
.get_ogg(tracknum
, track
, self
.artist
, self
.genre
, self
.album
, self
.year
)
672 print 'encoder died %d' % status
673 self
.status_update(tracknum
, 'enc_error', 0)
674 try: os
.unlink(strip_illegal(track
)+".wav")
676 try: os
.unlink(strip_illegal(track
)+".inf")
679 self
.is_encoding
= False
683 def status_update(self
, row
, state
, percent
):
684 '''Callback from rip/encode threads to update display'''
687 iter = self
.store
.get_iter((row
,))
692 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': %d%%' % percent
)
694 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': '+_('done'))
698 self
.store
.set_value(iter, COL_STATUS
, _('Encoding')+': %d%%' % percent
)
700 self
.store
.set_value(iter, COL_STATUS
, _('Encoding')+': '+_('done'))
702 if state
== 'rip_error':
703 self
.store
.set_value(iter, COL_STATUS
, _('Ripping')+': '+_('error'))
705 if state
== 'enc_error':
706 self
.store
.set_value(iter, COL_STATUS
, _('Encoding')+': '+_('error'))
711 def activate(self
, view
, path
, column
):
712 '''Edit a track name'''
713 model
, iter = self
.view
.get_selection().get_selected()
715 track
= model
.get_value(iter, COL_TRACK
)
716 dlg
= g
.Dialog(APP_NAME
)
717 dlg
.set_position(g
.WIN_POS_NONE
)
718 dlg
.set_default_size(350, 100)
719 (a
, b
) = dlg
.get_size()
720 (x
, y
) = self
.get_position()
721 (dx
, dy
) = self
.get_size()
722 dlg
.move(x
+dx
/2-a
/2, y
+dy
/2-b
/2)
726 entry
.set_text(track
)
727 dlg
.set_position(g
.WIN_POS_MOUSE
)
729 entry
.set_activates_default(True)
730 dlg
.vbox
.pack_start(entry
)
732 dlg
.add_button(g
.STOCK_OK
, g
.RESPONSE_OK
)
733 dlg
.add_button(g
.STOCK_CANCEL
, g
.RESPONSE_CANCEL
)
734 dlg
.set_default_response(g
.RESPONSE_OK
)
737 if response
== g
.RESPONSE_OK
:
738 track
= entry
.get_text()
740 model
.set_value(iter, COL_TRACK
, track
)
741 self
.view
.columns_autosize()
746 def toggle_check(self
, cell
, rownum
):
747 '''Toggle state for each song'''
748 row
= self
.store
[rownum
]
749 row
[COL_ENABLE
] = not row
[COL_ENABLE
]
750 self
.store
.row_changed(rownum
, row
.iter)
753 def set_selection(self
, thing
):
754 '''Get current selection'''
755 #model, iter = self.view.get_selection().get_selected()
757 # track = model.get_value(iter, COL_TRACK)
759 def button_press(self
, text
, event
):
760 '''Popup menu handler'''
761 if event
.button
!= 3:
763 self
.menu
.popup(self
, event
)
766 def show_options(self
, button
=None):
767 '''Show Options dialog'''
770 def get_options(self
):
771 '''Get changed Options'''
774 def delete_event(self
, ev
, e1
):
778 def close(self
, button
= None):
779 '''We're outta here!'''