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)
