#!/usr/bin/python # # ### license (ALv2) # # udev_cb.py: This script is invoked upon a device change in the optical # drive. It must analyze the situation and determine the # next steps. # import sys import os import fcntl import CDROM # linux-specific constants import syslog import subprocess import traceback import ConfigParser import multiprocessing import pwd import email.mime.text import time # Third-party package to fetch udev information. import pyudev ### assume that all of googlecode/gstein/trunk/ is checked out, and ### insert trunk/python/ into our path. THIS_DIR = os.path.dirname(os.path.realpath(__file__)) sys.path.insert(0, os.path.realpath(THIS_DIR + '/../../python')) # Helper script to detach us from the udev system. import daemonize CONFIG_FNAME = 'autorip.conf' ABCDE_CONFIG_FNAME = 'abcde.conf' CD_DISCID = '/usr/bin/cd-discid' EJECT = '/usr/bin/eject' BACKUP = '/usr/bin/dvdbackup' SENDMAIL = '/usr/sbin/sendmail' MOUNT = '/bin/mount' UMOUNT = '/bin/umount' NOTIFY_CMD = os.path.join(THIS_DIR, '../slack/notify.py') ### testing #BACKUP = '/bin/echo' # This can be set to something like '/dev/pts/3' to see debug output. # (it saves from logging lots of crap to the syslog) DEBUG_TERM = None #DEBUG_TERM = '/dev/pts/0' # Default category for all DVD copies is "movies" DEFAULT_CATEGORY = 'movies' def main(): """Process the 'change' event on the optical drive. NOTE: This function runs as a background process. """ # Read/parse the configuration. parser = ConfigParser.SafeConfigParser() parser.readfp(open(os.path.join(THIS_DIR, CONFIG_FNAME))) class _config(object): def __init__(self, items): vars(self).update(items) global CONFIG CONFIG = _config(parser.items('general')) # All syslog operations should use something besides "udev_cb.py" syslog.openlog("autorip") if CONFIG.disable == 'yes': syslog.syslog('skipping device change on %s (disabled)' % (CONFIG.drive,)) return if not disk_in_drive(): syslog.syslog('no disk in drive %s. skipping.' % (CONFIG.drive,)) return # Log the device change. syslog.syslog('handling device change on %s. uid=%s' % (CONFIG.drive, os.getuid())) try: main_actual() except: ei = sys.exc_info() msg = traceback.format_exception_only(ei[0], ei[1])[-1].strip() syslog.syslog('exception: ' + msg) # No way to recover. We need admin involvement msg = ''.join(traceback.format_exception(*ei)) notify('Autorip: exception occurred', 'Exception:\n%s' % (msg,)) def main_actual(): "Perform the real work, while our caller worries about exceptions." # Before doing anything, make sure we have the proper umask. os.umask(int(CONFIG.umask, 8)) ctx = pyudev.Context() device = pyudev.Device.from_device_file(ctx, CONFIG.drive) # We can inspect device status, regardless of whether it is mounted. # If there is no media in the drive, then possibly unmount the thing, # and then we're done. if not safebool(device, 'ID_CDROM_MEDIA'): if is_mounted(CONFIG.mount): syslog.syslog('media is missing. unmounting stale mountpoint: %s' % (CONFIG.mount,)) unmount(CONFIG.mount) else: syslog.syslog('no media in drive. skipping.') return # Depending on the media type, perform different actions if safebool(device, 'ID_CDROM_MEDIA_CD') \ or safebool(device, 'ID_CDROM_MEDIA_CD_R'): rip_cd(device) elif safebool(device, 'ID_CDROM_DVD') \ or safebool(device, 'ID_CDROM_MEDIA_DVD') \ or safebool(device, 'ID_CDROM_MEDIA_DVD_PLUS_R') \ or safebool(device, 'ID_CDROM_MEDIA_DVD_PLUS_RW'): rip_dvd(device) elif safebool(device, 'ID_CDROM_MEDIA_BD'): syslog.syslog('Blu-ray not implemented. ignoring.') else: syslog.syslog('unknown media type. ignoring.') notify('Autorip: unknown media type', 'The media on %s will be ignored.' % (CONFIG.mount,)) def is_mounted(mount_point): mp_stat = os.stat(mount_point) par_stat = os.stat(os.path.dirname(mount_point)) return mp_stat.st_dev != par_stat.st_dev def rip_cd(device): ### need to determine: data CD and/or music CD? # The drive should NOT be mounted, in order to rip its audio tracks. if is_mounted(CONFIG.mount): unmount(CONFIG.mount) # Have we seen this disc before? cddbid = subprocess.check_output([CD_DISCID, CONFIG.drive]).split()[0] if os.path.exists('%s/discid/%s' % (CONFIG.cd, cddbid)): syslog.syslog('skipping ID:%s ... already seen' % (cddbid,)) eject_drive() return # Direct ABCDE to only rip the audio tracks, not any potential data. ### figure that out n_audio = safeint(device, 'ID_CDROM_MEDIA_TRACK_COUNT_AUDIO') n_data = safeint(device, 'ID_CDROM_MEDIA_TRACK_COUNT_DATA') eject = (n_data == 0 and CONFIG.eject == 'yes') ### need to figure out non-interactive mode. particularly when there ### are multiple CDDB matches. if n_audio > 0: run_as_user(CONFIG.user, run_abcde, eject, n_audio, cddbid) ### if N_DATA, then we should have ID_FS_TYPE? what happens if N_DATA ### is larger than one? if 'ID_FS_TYPE' in device: # The CD has some data on it. label = device['ID_FS_LABEL'] ### is there more we could do? syslog.syslog('### skipping data track on CD: %s' % (label,)) notify('Autorip: skipping data track', 'The CD "%s" has a data track. Manual copying is needed.' % (label,)) mount(device['ID_FS_TYPE'], CONFIG.drive, CONFIG.mount) ### copy to $(CONFIG.cd)/$subdir/data ### if more than one data track, then dataN ? else: dbg('NO DATA') def run_abcde(eject, n_audio, cddbid): ### need to get the disc_id. record it. use it to avoid re-ripping ### completed CDs. abcde will resume partial-rips, but it will not ### avoid a full re-rip ### should move some config to the cmdline out of ABCDE_CONFIG_FNAME ### specifying non-interactive. but how do we choose which CDDB entry ### when there are conflicting versions? cmd = ['abcde', '-N', '-c', os.path.join(THIS_DIR, ABCDE_CONFIG_FNAME)] if eject: cmd.append('-x') cmd.append('1-%d' % n_audio) rv = subprocess.call(cmd) if rv == 0: syslog.syslog('completed audio rip of %s (ID:%s)' % (CONFIG.drive, cddbid)) else: syslog.syslog('warning: abcde exited with %d' % rv) notify('Autorip: abcde exited improperly', 'abcde exited with code %d' % (rv,)) def rip_dvd(device): dbg('ripping dvd (euid=%d) ...', os.geteuid()) label = device.get('ID_FS_LABEL', 'Unlabeled') mount(device['ID_FS_TYPE'], CONFIG.drive, CONFIG.mount) dbg('label="%s"', label) # Fetch the DVD's title. dvdbackup uses this for the target directory # name, and we need to avoid overwrites. info = subprocess.check_output([BACKUP, '-I', '-i', CONFIG.drive]) title = None for line in info.splitlines(): if line.startswith('DVD-Video '): title = line.split('"')[1] dbg('title="%s"', title) # TITLE may be None, if we didn't find the right line. Or the title # might be "". Create a title for either condition. if not title: # Not sure if this is possible, but let's make up a name. Notify the # admin to take further action. title = 'Unknown.%d' % (os.getpid(),) notify('Autorip: DVD has no title', 'Using title: "%s"' % (title,)) # If the size is 8 digits or more (ie. >= 10G), then somebody monkeyed # with the filesystem. A DVD cannot contain that much data. size = subprocess.check_output(['du', '-sk', CONFIG.mount]).split()[0] dbg('size=%s kbytes', size) if len(size) >= 8: syslog.syslog('%s has (too) large filesystem. skipping' % (CONFIG.mount,)) notify('Autorip: funny DVD filesystem', 'The DVD on %s ("%s"; "%s") has a too-large filesystem (%.1f gigabytes).' % (CONFIG.mount, label, title, float(size)/1000000)) # Record a marker, so we know we've looked at this DVD before open(os.path.join(CONFIG.dvd, CONFIG.toobig, title), 'w' ).write('LABEL: %s\nTITLE: %s\n%s kbytes\n' % (label, title, size)) # We don't really have alternatives, so just pop it out. if CONFIG.eject == 'yes': eject_drive() return # If the directory already exists, then we've done this one before, OR # a series of DVDs reused the same title information :-( ... switch to # something else, and notify the admin that additional handling is # needed (ie. remove or rename). if os.path.exists(os.path.join(CONFIG.dvd, title)): notify('Autorip: duplicate title', 'Title directory "%s" already exists. Switching to "%s.%d"' % (title, title, os.getpid())) title = '%s.%d' % (title, os.getpid()) dbg('Switching to: %s', title) dbg('invoke dvdbackup. title="%s"', title) t0 = time.time() run_as_user(CONFIG.user, run_dvdbackup, title) duration = (time.time() - t0) / 60.0 # Throw a quick notification to Slack that we're done. slack_notify('DVD copy of "%s" completed in %.1f minutes.' % (title, duration)) # Categorize this directory as a "movie". Other home tools will use # this category; a human may intervene to alter the category. # Note: this statement is basically a "touch" of the file. open(os.path.join(CONFIG.dvd, title, DEFAULT_CATEGORY), 'w') if CONFIG.eject == 'yes': eject_drive() def run_dvdbackup(title): subprocess.check_call([BACKUP, '-M', '-o', CONFIG.dvd, '-i', CONFIG.drive, '-n', title]) def mount(fstype, what, where): # Note: this assumes the current user is allowed to perform the mount, # from the implied device, at the given mount point, defined in /etc/fstab # We allow MOUNT to figure out the fstype. dbg('mounting ....') #subprocess.check_call([MOUNT, '-t', fstype, what, where]) try: subprocess.check_call([MOUNT, where]) except: ### fix this one day dbg('exception!') dbg('done') def unmount(path): subprocess.check_call([UMOUNT, path]) def eject_drive(): subprocess.check_call([EJECT, CONFIG.drive]) def disk_in_drive(): fd = os.open(CONFIG.drive, os.O_RDONLY | os.O_NONBLOCK) rv = fcntl.ioctl(fd, CDROM.CDROM_DRIVE_STATUS) os.close(fd) return rv == CDROM.CDS_DISC_OK def slack_notify(subject, text=None): if text: subprocess.check_output((NOTIFY_CMD, '-m', subject, '-s', text)) else: subprocess.check_output((NOTIFY_CMD, '-m', subject)) def notify(subject, text): slack_notify(subject, text) msg = email.mime.text.MIMEText(text) msg['Subject'] = subject msg['From'] = CONFIG.from_addr msg['To'] = CONFIG.to_addr p = subprocess.Popen([SENDMAIL, '-t', '-oi'], stdin=subprocess.PIPE) p.communicate(msg.as_string()) def run_as_user(user, func, *args, **kwargs): info = pwd.getpwnam(user) # If we're already the correct user/group, then just call the func. cur_uid = os.geteuid() cur_gid = os.getegid() if cur_uid == info.pw_uid and cur_gid == info.pw_gid: func(*args, **kwargs) return # Call the function in a subprocess, which will change the uid/gid. def switch(): os.initgroups(user, info.pw_gid) os.setgid(info.pw_gid) os.setuid(info.pw_uid) func(*args, **kwargs) # Spawn the subprocess, calling switch(), then wait for it to finish. p = multiprocessing.Process(target=switch) p.start() p.join() def safebool(device, propname): "Like Device.asbool(), but returns False if the property is missing." return bool(int(device.get(propname, 0))) def safeint(device, propname): "Like Device.asint(), but returns 0 if the property is missing." return int(device.get(propname, 0)) def dbg(fmt, *args): if DEBUG_TERM: open(DEBUG_TERM, 'w').write((fmt % args) + '\n') #open('/tmp/dbg.log','a').write((fmt % args) + '\n') if __name__ == '__main__': if os.geteuid() == 0: # We do NOT want to run as root. sys.exit(0) main()