Code to monitor and rip Blu-Ray upon disc insertion (linux)

General discussion about PS3 Media Server (no support or requests)

Code to monitor and rip Blu-Ray upon disc insertion (linux)

Postby tbabb » Tue Nov 29, 2011 8:55 am

I've coded this up for my own media server. Start the program and let it run in the background; when you stick a blu-ray in it'll immediately begin ripping to the desired location. It's a work in progress, but I figure some people might find it useful. I'll post updates as I make improvements if people are interested.

Requirements:
  • unix/linux
  • udev/udisks
  • python
  • makemkv

Things I intend to fix "soon":
  • Code to make this a proper daemon
  • support for ripping of ordinary DVDs via HandbrakeCLI
  • Automatic remux of ripped Blu-Ray MKV to .m2ts via tsMuxeR
  • Error handling, recovery, and logging is utter crap at the moment

Really this thing is so alpha I shouldn't even be posting it-- and I've only tested it on two disks-- but what the hell:

Code: Select all
#!/usr/bin/python

"""
dvdinsert
(c) 2011

Monitor optical disk drives, then use handbrake/makemkvcon to rip video discs
when they are inserted.
"""

# TODO: check file operations in rip() method
# TODO: user/chown/etc. (os.umask)
# TODO: daemonize/capture all errors and keep going
# TODO: log
# TODO: optionally remux to m2ts with tsMuxeR
# TODO: more verbose?

import os, sys
import subprocess as subp
import glob
import dbus
import gobject
import csv
from dbus.mainloop.glib import DBusGMainLoop

TRIGGER_DEVICES = ['/dev/sr0']
DESTINATION_DIR = '/home/media/video/movies'

progname = os.path.basename(sys.argv[0])
verbose  = True


###########################
# Global helpers          #
###########################


def Babble(s):
    # TODO: log.
    print "(%s): %s" % (progname, s)

def Msg(s):
    # TODO: log.
    print "(%s): %s" % (progname, s)

def Warn(s):
    # TODO: log.
    print >>sys.stderr, "WARNING (%s): %s" % (progname, s)

def uniquePath(p):
    """Uniquify the file path given by <p>, i.e. try to ensure that <p> does not
    already exist by adding digits if neccessary. Technically, this method has a
    race condition, as other processes may interfere after the existence tests
    have finished and the method returns. Does not create the file."""
   
    path, name = os.path.split(p)
    base, ext = os.path.splitext(name)
    n = 0
    while os.path.exists(p):
        p  = os.path.join(path, "%s.%d%s" % (base, n, ext))
        n += 1
    return p


###########################
# Device monitoring/dbus  #
###########################


def debugEvt(dev_name, dev_path, props):
    sep = ("=" * 60) + "\n"
    s = sep + "ChangedEvent from %s at %s received\n" % (dev_name, dev_path) + sep
    for k,v in props.iteritems():
        s += "%s : %s\n" % (k,v)
    return s


def convertDBusTypes(obj):
    """Convert DBus objects to native python formats.
    Because I like it better this way."""
   
    typ = type(obj).__name__.lower()
    if 'int' in typ or typ == 'byte':
        obj = int(obj)
    elif 'string' in typ:
        obj = str(obj)
    elif 'bool' in typ:
        obj = bool(obj)
    elif 'float' in typ:
        obj = float(obj)
    elif 'double' in typ:
        obj = float(obj)
    elif 'array' in typ:
        obj = [convertDBusTypes(x) for x in obj]
    elif 'dictionary' in typ:
        obj = dict([(convertDBusTypes(k), convertDBusTypes(v))
                    for k,v in obj.iteritems()])
    return obj


def deviceProperties(bus, dbus_dev_addr):
    """Return all properties of a device (given by its DBus address, e.g.
    '/org/freedesktop/UDisks/devices/sr0') as a dictionary."""
   
    dev_obj   = bus.get_object("org.freedesktop.UDisks", dbus_dev_addr)
    dev_props = dbus.Interface(dev_obj, "org.freedesktop.DBus.Properties")
    d = {}
    for prop in dev_props.GetAll(''):
        val = dev_props.Get('', prop)
        d[str(prop)] = convertDBusTypes(val)
    return d


def deviceChangedCallback(bus, dev_name, dev_path):
    """Called when (among other things) a disk is inserted."""
    devprops = deviceProperties(bus, dev_path)
   
    # print debugEvt(dev_name, dev_path, devprops)
   
    closed, avail, blank = [devprops[x] for x in ('OpticalDiscIsClosed',
                                'DeviceIsMediaAvailable',
                                'OpticalDiscIsBlank')]
    disc_kind = devprops['DriveMedia']
    if closed and avail and not blank:
        if 'optical_bd' in disc_kind:
            ripBluRay(dev_name, DESTINATION_DIR)
        elif 'optical_dvd' in disc_kind:
            ripDVD(dev_name)


def monitorDevices(device_array):
    # this is needed to monitor for callbacks:
    DBusGMainLoop(set_as_default=True)
   
    bus = dbus.SystemBus()     
    udisks = bus.get_object('org.freedesktop.UDisks',
                            '/org/freedesktop/UDisks')
    udisks = dbus.Interface(udisks, 'org.freedesktop.UDisks')
    for dev in device_array:
        try:
            dev_path = udisks.FindDeviceByDeviceFile(dev)
            dev_obj  = bus.get_object("org.freedesktop.UDisks", dev_path)
            dev_ifc  = dbus.Interface(dev_obj,
                               "org.freedesktop.UDisks.Device")
           
            # We use a lambda to store the device name with the callback
            # so we can tell which drive was changed from inside the function
            cbak = lambda: deviceChangedCallback(bus, dev, dev_path)
            dev_ifc.connect_to_signal('Changed', cbak)
            Msg("Monitoring %s" % dev)
        except:
            Warn("Device %s not found; will not monitor events" % dev)
   
    # begin monitoring
    mainloop = gobject.MainLoop()
    mainloop.run()


###########################
# Blu-ray ripping         #
###########################

# meaning of title/stream info codes from makemkvcon
# as defined in makemkvgui/inc/lgpl/apdefs.h
MAKEMKV_ATTRIBUTE_ENUMS = {
    0:  'unknown',
    1:  'type',
    2:  'name',
    3:  'langCode',
    4:  'langName',
    5:  'codecId',
    6:  'codecShort',
    7:  'codecLong',
    8:  'chapterCount',
    9:  'duration',
    10: 'diskSize',
    11: 'diskSizeBytes',
    12: 'streamTypeExtension',
    13: 'bitrate',
    14: 'audioChannelsCount',
    15: 'angleInfo',
    16: 'sourceFileName',
    17: 'audioSampleRate',
    18: 'audioSampleSize',
    19: 'videoSize',
    20: 'videoAspectRatio',
    21: 'videoFrameRate',
    22: 'streamFlags',
    23: 'dateTime',
    24: 'originalTitleId',
    25: 'segmentsCount',
    26: 'segmentsMap'
}


def bluRayDiscProperties(device):
    """Use makemkvcon to enumerate the properties of a blu-ray movie disc.
    Note that this method may be quite slow due to I/O (probably too slow for an
    interactive application).
   
    Result is returned like:
        (disc_properties, titles)
   
    where disc_properties is:
        {'property' : value}
   
    and titles is:
        {title_id : {'property' : value
                     'streams'  : {stream_id : {'property' : value}}
                    }
        }
   
    Returns None on error.
    """
   
    # get the properties in (almost) csv format from makemkvcon
    pipe = subp.Popen(['makemkvcon', '-r', 'info', 'dev:%s' % device],
                      stdout=subp.PIPE, stderr=subp.PIPE)
    sout, serr = pipe.communicate()
   
    if pipe.returncode != 0:
        Warn("Could not acquire blu-ray title info from %s" % device)
        Warn("makemkvcon output:\n%s" % serr)
        return None
    else:
        # parse comma-separated messages, accounting for quoted strings.
        # this is one line. I heart python.
        parsed = [x for x in csv.reader(sout.split("\n"))]
       
        disc   = {}
        titles = {}
        for ifo in parsed:
            if len(ifo) == 0:
                # blank line
                continue
           
            # first field contains data after the ":", make it a proper column
            data = ifo[0].split(":", 1) + ifo[1:]
           
            # make integers where possible
            for i, field in enumerate(data):
                try:
                    data[i] = int(field)
                except ValueError:
                    # not an int, apparently
                    pass
           
            key = data[0]
            dst = None
            val = None
            property = None
            if key == 'CINFO':
                # disk info
                property, code, val = data[1:]
                dst = disc
            elif key == 'TINFO':
                # track info
                title, property, code, val = data[1:]
                if title not in titles:
                    titles[title] = {'streams' : {}}
                dst = titles[title]
            elif key == 'SINFO':
                # stream info
                title, stream, property, code, val = data[1:]
                if title not in titles:
                    titles[title] = {'streams' : {}}
                if stream not in titles[title]['streams']:
                    titles[title]['streams'][stream] = {}
                dst = titles[title]['streams'][stream]
           
            if dst is not None:
                dst[MAKEMKV_ATTRIBUTE_ENUMS[property]] = val
        return (disc, titles)


def durationToSeconds(duration):
    """Convert a timecode to raw seconds"""
    parts = map(int, duration.split(":"))
    if len(parts) == 3:
        return parts[0] * 3600 + parts[1] * 60 + parts[2]
    elif len(parts) == 2:
        return parts[0] * 60 + parts[1]
    else:
        raise ValueError("could not parse timecode")


def detectBluRayMainFeature(titles):
    """Attempt to guess the main feature using metrics such as time,
    tracks, number of chapters, etc. Return the number of the detected title.
   
    May not work quite right, as movie studios like to do crazy shit to try and
    confuse algorithms like this one."""
   
    title_metrics = []
   
    # 'stream x is of type t' predicate function
    istyp = lambda x, t: lambda x: 'type' in x and t in x['type'].lower()
   
    # gather info about each title
    for i,t in titles.iteritems():
        streams  = t['streams']
        duration = durationToSeconds(t['duration']) if 'duration' in t else 0
        n_subt   = len(filter(lambda x: istyp(x, 'subtitle'), streams))
        n_audio  = len(filter(lambda x: istyp(x, 'audio'), streams))
        n_chapt  = t['chapterCount'] if 'chapterCount' in t else 0
       
        title_metrics.append((duration, n_subt, n_audio, n_chapt, i))
   
    # some importance weights that I very
    # scientifically pulled out of my butt:
    wts = [.81, 0.089, 0.071, 0.030]
   
    # largest of each metric, so we can normalize
    max_of_field = [max([float(x[i]) for x in title_metrics])
                    for i in range(len(wts))]
   
    # prune out any titles shorter than 75% of longest title
    title_metrics = filter(lambda x: x[0] > 0.75 * max_of_field[0], title_metrics)
   
    # sort according to weighted sum of buttsourced metrics, highest first
    final_metrics = []
    for row in title_metrics:
        wt = sum([row[i] * wts[i] / max_of_field[i] for i in range(len(wts))])
        final_metrics.append((wt, row[-1]))
    final_metrics.sort(reverse=True)
   
    # the "best"
    return final_metrics[0][-1]


def ripBluRay(device, destDir, tmpDir=None):
    """Use makemkvcon to rip a blu-ray movie from the given device.
    <destDir> is the path of the folder into which finished ripped movies will
    be moved. <tmpDir> is the path of a folder where unfinished rips will reside
    until they are complete. If <tmpDir> is None, then temporary folders will be
    created in <destDir>."""
   
    properties = bluRayDiscProperties(device)
    if properties is None:
        # failure. brdProperties() will have reported the error.
        return False
   
    disc, titles = properties
    feature_title_id = detectBluRayMainFeature(titles)
    name = disc['name'] if 'name' in disc else 'Unknown Blu-Ray'
   
    # create unique directory for new mkv file
    desired_tmp = os.path.join(destDir if tmpDir is None else tmpDir,
                               "tmp.%s.ripdir" % name)
    tmpdest = uniquePath(desired_tmp)
    os.makedirs(tmpdest)
   
    Msg("Ripping title %s of %s to %s" % (feature_title_id, name, tmpdest))
   
    pipe = subp.Popen(["makemkvcon",
                       "mkv",
                       "dev:%s" % device,
                       str(feature_title_id),
                       tmpdest],
                       stdout=subp.PIPE, stderr=subp.PIPE)
    sout, serr = pipe.communicate()
   
    if pipe.returncode != 0:
        Warn("Failed to rip from '%s' %s" % (disc['title'], device))
        Warn("makemkvcon output:\n%s" % serr)
        # leave the tmpdir and unfinished mkv laying around for debugging
        return False
    else:
        fs = glob.glob(os.path.join(tmpdest,"*.mkv"))
        if len(fs) != 1:
            # makemkvcon should only create one file. somebody else probably
            # put something here. just back away slowly.
            Warn("Multiple MKV files in temp rip directory %s; not moving " \
                 "any files back to %s" % (tmpdst, destDir))
            return False
        else:
            # move tmp mkv to final location
            final_filename = "%s.mkv" % name
            final_path = uniquePath(os.path.join(destDir, final_filename))
            os.rename(fs[0], final_path)
            os.rmdir(tmpdest)
            subp.call(['eject', device])
            Msg("Ripped %s successfully" % name)
            return True


###########################
# DVD ripping             #
###########################


def ripDVD(device):
    # not implemented
    pass


###########################
# Entry point             #
###########################

monitorDevices(TRIGGER_DEVICES)

tbabb
 
Posts: 7
Joined: Mon Oct 17, 2011 6:32 am

Return to General Discussion

Who is online

Users browsing this forum: Bing [Bot] and 5 guests