#!/usr/bin/env python # # Copyright (c) 2006-2007, Gregory Fleischer (gfleischer@gmail.com) # # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in # the documentation and/or other materials provided with the # distribution. # 3. The names of the authors may not be used to endorse or promote # products derived from this software without specific prior # written permission. # # THIS SOFTWARE IS PROVIDED ``AS IS'' AND WITHOUT ANY EXPRESS OR # IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. # import sys, re, getopt, os import CDDB, DiscID import threading, subprocess, Queue, time class RypprType: WAV = "wav" OGG = "ogg" MP3 = "mp3" MP3_LOW = "mp3-low" class RypprError(Exception): def __init__(self, message): Exception.__init__(self, message) class RypprConf: def __init__(self): # defaults self.cdrom = '/dev/cdrom' self.oggargs = '-q 7' self.lameargs = '-V 4 -q0 --vbr-new' self.lamelowargs = '-V8 -q0 -B128 --vbr-new --resample 24' self.outputdir = '~/ryppd' self.keepwav = False self.debug = False self.cdparanoia = self.find_exec('cdparanoia') self.lame = self.find_exec('lame') self.oggenc = self.find_exec('oggenc') self.eject = self.find_exec('eject') self.cddburl = 'http://freedb.freedb.org/~cddb/cddb.cgi' self.read_init() def find_exec(self, exe): for p in os.getenv("PATH").split(os.pathsep): if os.path.isfile(os.path.join(p, exe)): return os.path.join(p, exe) else: return os.path.join(os.curdir, exe) def read_init(self): init_file = os.path.join(os.getenv("HOME"), ".cdryppr") lines = [] if os.path.isfile(init_file): for line in open(init_file, 'r').readlines(): try: line = re.sub(r'#.*$', '', line); if line and re.match(r'=', line): a, v = re.split(r'\s*=\s*', line) if a and v: f = getattr(self, a) if not callable(f): if type(f) is bool: if "True" == v: b = True else: b = False setattr(self, a, b) else: setattr(self, a, v) except AttributeError, e: print "unrecognized config value: %s" % e pass else: try: file = open(init_file, 'w') file.write("# begin cdryppr config file\n\n") except: return configs = [a for a in dir(self) if not callable(getattr(self, a)) and not re.match('__', a)] for c in configs: file.write("%s=%s\n" % (c, getattr(self, c))) file.write("\n# end cdryppr config file\n") class Ryppr: def __init__(self, conf): self.cdrom = conf.cdrom self.oggargs = conf.oggargs self.lameargs = conf.lameargs self.lamelowargs = conf.lamelowargs self.outputdir = conf.outputdir.replace('~/', os.getenv('HOME') + "/") self.keepwav = conf.keepwav self.cdparanoia = conf.cdparanoia self.lame = conf.lame self.oggenc = conf.oggenc self.eject = conf.eject self.cddburl = conf.cddburl self.debug = conf.debug check = {'cdparanoia' : self.cdparanoia, 'lame' : self.lame, 'oggenc' : self.oggenc, 'eject' : self.eject} for k in check.keys(): if not os.path.exists(check[k]): raise RypprError("missing %s; expected %s" % (k, check[k])) if not os.path.exists(self.outputdir): os.makedirs(self.outputdir) self.workq = Queue.Queue() self.cdreader = threading.Thread(target = self.cdreader_impl) self.encoder = threading.Thread(target = self.encoder_impl) self.ripping_complete = False self.lame_genres = {} args = [self.lame, '--genre-list'] p = subprocess.Popen(args, stdout=subprocess.PIPE) for line in p.stdout.xreadlines(): g = line.rstrip().split() self.lame_genres[g[1]] = g[0] p.stdout.close() rc = p.wait() def get_cdaudio_tracks(self): args = [self.cdparanoia, '-e', '-Q', '-d', self.cdrom] p = subprocess.Popen(args, stderr=subprocess.PIPE) in_toc = 0 tracks = [] for line in p.stderr.xreadlines(): line = line.rstrip() if in_toc: if re.match(r"TOTAL\s+\d+", line): in_toc = 0 else: m = re.match("\s+(\d+)\.\s+\d+", line) if m: tracks.append( m.group(1) ) else: print "BAD CD LINE: ", line else: if re.match(r"=========", line): in_toc = 1 p.stderr.close() rc = p.wait() return tracks def get_local_cdaudio_info(self, disc_info): if disc_info is None: return None disc_id = "%08lx" % disc_info[0] num_tracks = disc_info[1] cddb_file = os.path.join(os.getenv("HOME"), os.path.join(".cddb", disc_id)) if not os.path.exists(cddb_file): return None lines = open(cddb_file, 'r').readlines() info = {} tracks = [] disctitle, artist, alblum = None, None, None disc_re = re.compile(r'D(TITLE|GENRE|YEAR)=(.*)') track_re = re.compile(r'T(TITLE|ARTIST)(\d+)=(.*)') for line in lines: line = line.rstrip() m = disc_re.match(line) if m: (type, data) = m.groups() if "TITLE" == type: disctitle = data.strip() album = disctitle artist = "unknown" if 0 != disctitle.count("/"): spos = disctitle.index("/") artist = disctitle[0:spos].strip() album = disctitle[spos+1:].strip() elif "GENRE" == type: info["genre"] = data.strip() elif "YEAR" == type: info["year"] = data.strip() continue m = track_re.match(line) if m: (type, number, data) = m.groups() number = int(number) if "TITLE" == type: while len(tracks) <= number: tracks.append("") tracks[number] = data.strip() continue if disctitle is None: return None info['disctitle'] = disctitle info['artist'] = artist info['album'] = album info['tracks'] = tracks self.debug_print("using info from [%s]" % cddb_file) self.debug_print("info: %s" % (info)) self.debug_print('disctitle=(%s): artist (%s), album (%s)' % (disctitle, artist, album)) return info def get_cdaudio_info(self): cdrom = DiscID.open(self.cdrom) disc_id = DiscID.disc_id(cdrom) local_info = self.get_local_cdaudio_info(disc_id) if local_info is not None: return local_info (query_status, query_info) = CDDB.query(disc_id, self.cddburl) self.debug_print("status=%s, query_info=%s" % (query_status, query_info)) if query_info is None: return None if list == type(query_info): if 1 == len(query_info): query_info = query_info[0] else: possible = {} max_count = 0 best_choice = None # multiple returned - pick one ? for qi in query_info: n = self.normalize(qi['title']) if qi['title'] == qi['title'].upper(): # ignore all upper if n not in possible: possible[n] = 0 elif n in possible: possible[n] += 1 else: possible[n] = 1 if possible[n] > max_count: max_count = possible[n] best_choice = qi if 1 == max_count: # need to pick while True: choice = 0 for qi in query_info: choice += 1 print "[%d] %s" % (choice, qi['title']) try: choice = int(raw_input("pick one: ")) except Exception, error: print "ERROR: %s" % (error) choice = 0 if -1 == choice: return None if (choice > 0 and choice <= len(query_info)): query_info = query_info[choice - 1] break else: query_info = best_choice disctitle = query_info['title'] album = disctitle artist = "unknown" if 0 != disctitle.count("/"): spos = disctitle.index("/") artist = disctitle[0:spos].strip() album = disctitle[spos+1:].strip() self.debug_print('disctitle=(%s): artist (%s), album (%s)' % (disctitle, artist, album)) info = { 'disctitle' : disctitle, 'artist' : artist, 'album' : album, 'tracks' : [] } (read_status, read_info) = CDDB.read(query_info['category'], query_info['disc_id'], self.cddburl) info['genre'] = read_info['DGENRE'] info['year'] = read_info['DYEAR'] for i in range(disc_id[1]): track = read_info['TTITLE' + `i`] info['tracks'].append(track) self.debug_print("Track %.02d: %s" % (i, track)) return info def rip_cd_tracks(self): self.message("checking for cd audio") tracks = self.get_cdaudio_tracks() num_tracks = len(tracks) self.debug_print( "found %d tracks" % (num_tracks)) if 0 == num_tracks: self.message("no audio tracks detected; not encoding") return -1 info = self.get_cdaudio_info() if info is None: self.message("unable to find any matching info; not encoding") return 1 if len(tracks) != len(info['tracks']): if len(tracks)+1 == len(info['tracks']): last_track = info['tracks'][len(tracks)].upper() if not last_track in ["DATA", "DATA TRACK", "EXTRA"]: self.message("possible track mismatch; %d versus %d" % (len(tracks), len(info['tracks']))) else: self.message("track count mismatch; %d versus %d" % (len(tracks), len(info['tracks']))) return 1 self.message('processing artist (%s), album (%s)' % (info['artist'], info['album'])) target_dir = self.make_target_dir(info, RypprType.WAV) # rip tracks for i in range(len(tracks)): time.sleep(2) # ? tracknumber = i + 1 # 1-based filename = "%02d-%s.wav" % (tracknumber, self.normalize(info['tracks'][i])) targetfile = os.path.join(target_dir, filename) self.debug_print("targetfile=%s" % targetfile) if self.need_rip_and_process(info, filename): failed = False if not os.path.exists(targetfile): args = [self.cdparanoia, '-d', self.cdrom, '-X', '-e', str(tracknumber), targetfile] self.message("ripping track #%02d of %02d to %s" % (tracknumber, len(tracks), filename)) p = subprocess.Popen(args, stderr=subprocess.PIPE) for line in p.stderr.xreadlines(): # todo figure out how to determine percentage complete and report pass p.stderr.close() rc = p.wait() if 0 != rc: print "Error: " + line os.unlink(targetfile) failed = True elif not os.path.exists(targetfile): failed = True else: failed = False if failed: self.message("***failed on #%02d (%s); rc=(%d)" % (tracknumber, filename, rc)) else: workitem = { 'artist' : info['artist'], 'album' : info['album'], 'title' : info['tracks'][i], 'tracknumber' : tracknumber, 'wavfile' : targetfile, 'filename' : filename, 'year' : info['year'], 'genre' : info['genre'], } self.workq.put(workitem) else: if os.path.exists(targetfile): pass self.message("ripping completed") return 0 def cdreader_impl(self): self.debug_print( "cdreader_impl: using %s" % (self.cdparanoia) ) try: keep_looping = True while keep_looping: self.ripping_started = False rc = self.rip_cd_tracks() if 0 > rc: if self.workq.empty(): keep_looping = False else: for t in range(2): time.sleep(15) # let settle args = [self.eject, self.cdrom] self.message("ejecting %s" % (self.cdrom)) p = subprocess.Popen(args) rtn = p.wait() if 0 == rtn: break time.sleep(60) # sleep sixty seconds finally: self.ripping_complete = True self.message("exiting ripping") def get_base_file(self, file): return file[0:-4] # strip off .wav def need_rip_and_process(self, info, filename): basefile = self.get_base_file(filename) oggfile = os.path.join(self.make_target_dir(info, RypprType.OGG), basefile + ".ogg") mp3file = os.path.join(self.make_target_dir(info, RypprType.MP3), basefile + ".mp3") mp3lowfile = os.path.join(self.make_target_dir(info, RypprType.MP3_LOW), basefile + ".mp3") if os.path.exists(oggfile) and os.path.exists(mp3file) and os.path.exists(mp3lowfile): return False else: return True def normalize(self, name): nn = re.sub(r"[^a-zA-Z0-9_]+", "_", name.replace("'", "")).lower() if nn.startswith("_"): nn = nn[1:] if nn.endswith("_"): nn = nn[0:-1] return nn def make_target_dir(self, info, ryppr_type): target_dir = os.path.join(os.path.join(self.outputdir, ryppr_type), os.path.join(self.normalize(info['artist']), self.normalize(info['album']))) if not os.path.exists(target_dir): self.debug_print("using %s target directory %s" % (ryppr_type, target_dir)) os.makedirs(target_dir) return target_dir def encoder_impl(self): self.debug_print( "encoder_impl: using %s, %s" % (self.lame, self.oggenc)) keep_looping = True while keep_looping: try: workitem = self.workq.get(True, 15) self.debug_print("got workitem: %s" % (workitem)) artist = workitem['artist'] album = workitem['album'] title = workitem['title'] genre = workitem['genre'] year = workitem['year'] tracknumber = "%02d" % workitem['tracknumber'] wavfile = workitem['wavfile'] filename = workitem['filename'] basefile = self.get_base_file(filename) oggfile = os.path.join(self.make_target_dir(workitem, RypprType.OGG), basefile + ".ogg") mp3file = os.path.join(self.make_target_dir(workitem, RypprType.MP3), basefile + ".mp3") mp3lowfile = os.path.join(self.make_target_dir(workitem, RypprType.MP3_LOW), basefile + ".mp3") if not os.path.exists(oggfile): args = [ self.oggenc, self.oggargs, '-a', artist, '-l', album, '-t', title, '-N', tracknumber, '-G', genre, '-d', year, '-o', oggfile, wavfile ] self.debug_print("encoding track #%s to %s" % (tracknumber, oggfile)) p = subprocess.Popen(args, stderr=subprocess.PIPE) for line in p.stderr.xreadlines(): # todo figure out how to determine percentage complete and report pass p.stderr.close() rc = p.wait() if not os.path.exists(mp3file): lame_genre = genre if not self.lame_genres.has_key(genre): lame_genre = 'Other' args = [ self.lame, self.lameargs, '--ta', artist, '--tl', album, '--tt', title, '--tn', tracknumber, '--tg', lame_genre, '--ty', year, wavfile, mp3file ] self.debug_print("encoding track #%s to %s" % (tracknumber, mp3file)) p = subprocess.Popen(args, stderr=subprocess.PIPE) for line in p.stderr.xreadlines(): # todo figure out how to determine percentage complete and report pass p.stderr.close() rc = p.wait() if not os.path.exists(mp3lowfile): lame_genre = genre if not self.lame_genres.has_key(genre): lame_genre = 'Other' args = [ self.lame, self.lamelowargs, '--ta', artist, '--tl', album, '--tt', title, '--tn', tracknumber, '--tg', lame_genre, '--ty', year, wavfile, mp3lowfile ] self.debug_print("encoding track #%s to %s" % (tracknumber, mp3lowfile)) p = subprocess.Popen(args, stderr=subprocess.PIPE) for line in p.stderr.xreadlines(): # todo figure out how to determine percentage complete and report pass p.stderr.close() rc = p.wait() if os.path.exists(oggfile) and os.path.exists(mp3file) and os.path.exists(mp3lowfile): if os.path.exists(wavfile) and not self.keepwav: os.unlink(wavfile) if self.workq.empty() and self.ripping_complete: keep_looping = False except Queue.Empty, qe: self.debug_print("the queue was empty") if self.workq.empty() and (self.ripping_complete): keep_looping = False except OSError, oe: self.message("caught error: %s" % (oe)) if self.workq.empty() and (self.ripping_complete): keep_looping = False except KeyboardInterrupt: self.message("caught keyboard interrupt") keep_looping = False def debug_print(self, msg): if self.debug: print "DEBUG: ", msg else: pass def message(self, msg): print msg def Rip(self): self.message("starting") self.message("starting cdreader") self.cdreader.start() self.message("starting encoder") self.encoder.start() self.message("waiting for cdreader to stop") self.cdreader.join() self.message("waiting for encode to stop") self.encoder.join() self.message("stopped") if '__main__' == __name__: try: conf = RypprConf() opts, args = getopt.getopt(sys.argv[1:], 'c:o:kd', ['cdrom=', 'outdir=', 'keep-wave', 'debug'] ) for o, v in opts: if o in ("-c", "--cdrom"): if not os.path.exists(v): raise RypprError("invalid cdrom '%s'" % v) conf.cdrom = v elif o in ("-o", "--outdir"): conf.outdir = v elif o in ("-k", "--keep-wave"): conf.keepwav = True elif o in ("-d", "--debug"): conf.debug = True else: raise RypprError("unexpected option %s, %s" % (o,v)) ryppr = Ryppr(conf) ryppr.Rip() except getopt.GetoptError, e: print "GetoptError: ", e except RypprError, e: print "RypprError: ", e # eof