You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

709 lines
26 KiB

#!/usr/bin/env python3
# coding=utf-8
"""
based on this: https://github.com/xenomachina/dvdrip
Rip DVDs quickly and easily from the commandline.
Features:
- With minimal configuration:
- Encodes videos in mkv files with h.264 video and aac audio.
(compatible with a wide variety of media players without
additional transcoding, including PS3, Roku, and most smart
phones, smart TVs and tablets).
- Preserves all audio tracks, all subtitle tracks, and chapter
markers.
- Intelligently chooses output filename based on a provided prefix.
- Generates one video file per DVD title, or optionally one per
chapter.
- Easy to read "scan" mode tells you what you need need to know about
a disk to decide on how to rip it.
Why I wrote this:
This script exists because I wanted a simple way to back up DVDs with
reasonably good compression and quality settings, and in a format I could
play on the various media players I own including PS3, Roku, smart TVs,
smartphones and tablets. Using mkv files with h.264 video and aac audio seems
to be the best fit for these constraints.
I also wanted it to preserve as much as possible: chapter markers, subtitles,
and (most of all) *all* of the audio tracks. My kids have a number of
bilingual DVDs, and I wanted to back these up so they don't have to handle
the physical disks, but can still watch their shows in either language. For
some reason HandBrakeCLI doesn't have a simple “encode all audio tracks”
option.
This script also tries to be smart about the output name. You just tell it
the pathname prefix, eg: "/tmp/AwesomeVideo", and it'll decide whether to
produce a single file, "/tmp/AwesomeVideo.mkv", or a directory
"/tmp/AwesomeVideo/" which will contain separate files for each title,
depending on whether you're ripping a single title or multiple titles.
Using it, Step 1:
The first step is to scan your DVD and decide whether or not you want
to split chapters. Here's an example of a disc with 6 episodes of a TV
show, plus a "bump", all stored as a single title.
$ dvdrip --scan /dev/cdrom
Reading from '/media/EXAMPLE1'
Title 1/ 1: 02:25:33 720×576 4:3 25 fps
audio 1: Chinese (5.1ch) [48000Hz, 448000bps]
chapter 1: 00:24:15 ◖■■■■■■■■■‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥◗
chapter 2: 00:24:15 ◖‥‥‥‥‥‥‥‥■■■■■■■■■‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥◗
chapter 3: 00:24:14 ◖‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥■■■■■■■■■‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥◗
chapter 4: 00:24:15 ◖‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥■■■■■■■■■■‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥◗
chapter 5: 00:24:15 ◖‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥■■■■■■■■■‥‥‥‥‥‥‥‥◗
chapter 6: 00:24:14 ◖‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥■■■■■■■■■◗
chapter 7: 00:00:05 ◖‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥■◗
Knowing that this is 6 episodes of a TV show, I'd choose to split the
chapters. If it was a movie with 6 chapters, I would choose to not
split it.
Here's a disc with 3 2-segment episodes of a show, plus two "bumps",
stored as 8 titles.
Reading from '/media/EXAMPLE2'
Title 1/ 5: 00:23:22 720×576 4:3 25 fps
audio 1: Chinese (2.0ch) [48000Hz, 192000bps]
audio 2: English (2.0ch) [48000Hz, 192000bps]
sub 1: English [(Bitmap)(VOBSUB)]
chapter 1: 00:11:41 ◖■■■■■■■■■■■■■■■■■■■■■■■■■‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥◗
chapter 2: 00:11:41 ◖‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥■■■■■■■■■■■■■■■■■■■■■■■■■■◗
Title 2/ 5: 00:22:40 720×576 4:3 25 fps
audio 1: Chinese (2.0ch) [48000Hz, 192000bps]
audio 2: English (2.0ch) [48000Hz, 192000bps]
sub 1: English [(Bitmap)(VOBSUB)]
chapter 1: 00:11:13 ◖■■■■■■■■■■■■■■■■■■■■■■■■‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥◗
chapter 2: 00:11:28 ◖‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥■■■■■■■■■■■■■■■■■■■■■■■■■◗
Title 3/ 5: 00:22:55 720×576 4:3 25 fps
audio 1: Chinese (2.0ch) [48000Hz, 192000bps]
audio 2: English (2.0ch) [48000Hz, 192000bps]
sub 1: English [(Bitmap)(VOBSUB)]
chapter 1: 00:15:56 ◖■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥◗
chapter 2: 00:06:59 ◖‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥‥■■■■■■■■■■■■■■■■◗
Title 4/ 5: 00:00:08 720×576 4:3 25 fps
audio 1: English (2.0ch) [None]
chapter 1: 00:00:08 ◖◗
Title 5/ 5: 00:00:05 720×576 4:3 25 fps
chapter 1: 00:00:05 ◖◗
Given that these are 2-segment episodes (it's pretty common for kids'
shows to have two segments per episode -- essentially 2 "mini-episodes") you
can choose whether to do the default one video per title (episodes) or
split by chapter (segments / mini-episodes).
Using it, Step 2:
If you've decided to split by chapter, execute:
dvdrip.py -c /dev/cdrom -o Output_Name
Otherwise, leave out the -c flag.
If there is only one video being ripped, it will be named Output_Name.mkv. If
there are multiple files, they will be placed in a new directory called
Output_Name.
Limitations:
This script has been tested on both Linux and Mac OS X with Python 3,
HandBrakeCLI and VLC installed (and also MacPorts in the case of OS X).
"""
# TODO: Detect if HandBrakeCLI is burning in vobsubs.
import argparse
import os
import re
import stat
import subprocess
import sys
import time
from pprint import pprint
from collections import namedtuple
from fractions import gcd
class UserError(Exception):
def __init__(self, message):
self.message = message
CHAR_ENCODING = 'UTF-8'
def check_err(*popenargs, **kwargs):
process = subprocess.Popen(stderr=subprocess.PIPE, *popenargs, **kwargs)
_, stderr = process.communicate()
retcode = process.poll()
if retcode:
cmd = kwargs.get("args")
if cmd is None:
cmd = popenargs[0]
raise subprocess.CalledProcessError(retcode, cmd, output=stderr)
return stderr.decode(CHAR_ENCODING, 'replace')
def check_output(*args, **kwargs):
return subprocess.check_output(*args, **kwargs).decode(CHAR_ENCODING)
HANDBRAKE = 'HandBrakeCLI'
TITLE_COUNT_REGEXES = [
re.compile(r'^Scanning title 1 of (\d+)\.\.\.$'),
re.compile(r'^\[\d\d:\d\d:\d\d] scan: DVD has (\d+) title\(s\)$'),
]
def FindTitleCount(scan, verbose):
for regex in TITLE_COUNT_REGEXES:
for line in scan:
m = regex.match(line)
if m: break
if m:
return int(m.group(1))
if verbose:
for line in scan:
print(line)
raise AssertionError("Can't find TITLE_COUNT_REGEX in scan")
STRUCTURED_LINE_RE = re.compile(r'( *)\+ (([a-z0-9 ]+):)?(.*)')
def ExtractTitleScan(scan):
result = []
in_title_scan = False
for line in scan:
if not in_title_scan:
if line.startswith('+'):
in_title_scan = True
if in_title_scan:
m = STRUCTURED_LINE_RE.match(line)
if m:
result.append(line)
else:
break
return tuple(result)
TRACK_VALUE_RE = re.compile(r'(\d+), (.*)')
def MassageTrackData(node, key):
if key in node:
track_data = node[key]
if type(track_data) is list:
new_track_data = {}
for track in track_data:
k, v = TRACK_VALUE_RE.match(track).groups()
new_track_data[k] = v
node[key] = new_track_data
def ParseTitleScan(scan):
pos, result = ParseTitleScanHelper(scan, pos=0, indent=0)
# HandBrakeCLI inexplicably uses a comma instead of a colon to
# separate the track identifier from the track data in the "audio
# tracks" and "subtitle tracks" nodes, so we "massage" these parsed
# nodes to get a consistent parsed reperesentation.
for value in result.values():
MassageTrackData(value, 'audio tracks')
MassageTrackData(value, 'subtitle tracks')
return result
def ParseTitleScanHelper(scan, pos, indent):
result = {}
cruft = []
while True:
pos, node = ParseNode(scan, pos=pos, indent=indent)
if node:
if type(node) is tuple:
k, v = node
result[k] = v
else:
cruft.append(node)
result[None] = cruft
else:
break
if len(result) == 1 and None in result:
result = result[None]
return pos, result
def ParseNode(scan, pos, indent):
if pos >= len(scan):
return pos, None
line = scan[pos]
spaces, colon, name, value = STRUCTURED_LINE_RE.match(line).groups()
spaces = len(spaces) / 2
if spaces < indent:
return pos, None
assert spaces == indent, '%d <> %r' % (indent, line)
pos += 1
if colon:
if value:
node = (name, value)
else:
pos, children = ParseTitleScanHelper(scan, pos, indent + 1)
node = (name, children)
else:
node = value
return pos, node
def only(iterable):
"""
Return the one and only element in iterable.
Raises an ValueError if iterable does not have exactly one item.
"""
result, = iterable
return result
Title = namedtuple('Title', ['number', 'info'])
Task = namedtuple('Task', ['title', 'chapter'])
TOTAL_EJECT_SECONDS = 5
EJECT_ATTEMPTS_PER_SECOND = 10
class DVD:
def __init__(self, mountpoint, verbose, mount_timeout=0):
if stat.S_ISBLK(os.stat(mountpoint).st_mode):
mountpoint = FindMountPoint(mountpoint, mount_timeout)
if not os.path.isdir(mountpoint):
raise UserError('%r is not a directory' % mountpoint)
self.mountpoint = mountpoint
self.verbose = verbose
def RipTitle(self, task, output, dry_run, verbose):
if verbose:
print('Title Scan:')
pprint(task.title.info)
print('-' * 78)
english_audio = {k: v for k, v in task.title.info['audio tracks'].items() if 'english' in v.lower() }
english_subs = {k: v for k, v in task.title.info['subtitle tracks'].items() if 'english' in v.lower() }
#audio_tracks = task.title.info['audio tracks'].keys()
audio_tracks = english_audio.keys()
audio_encoders = ['faac'] * len(audio_tracks)
#subtitles = task.title.info['subtitle tracks'].keys()
subtitles = english_subs.keys()
args = [
HANDBRAKE,
'--title', str(task.title.number),
'--preset', "Very Fast 1080p30",
'--vfr',
'--encoder', 'x264',
'--audio', ','.join(audio_tracks),
'--aencoder', ','.join(audio_encoders),
]
if task.chapter is not None:
args += [
'--chapters', str(task.chapter),
]
if subtitles:
args += [
'--subtitle', ','.join(subtitles),
]
args += [
'--markers',
'--optimize',
#'--no-dvdnav', # TODO: turn this on as a fallback
'--input', self.mountpoint,
'--output', output,
]
if verbose:
print(' '.join(('\n ' + a)
if a.startswith('-') else a for a in args))
print('-' * 78)
pprint( args )
if not dry_run:
if verbose:
subprocess.call(args)
else:
check_err(args)
def ScanTitle(self, i):
for line in check_err([
HANDBRAKE,
#'--no-dvdnav', # TODO: turn this on as a fallback
'--scan',
'--title', str(i),
'-i',
self.mountpoint], stdout=subprocess.PIPE).split('\n'):
if self.verbose:
print('< %s' % line.rstrip())
yield line
def ScanTitles(self, title_numbers, verbose):
"""
Returns an iterable of parsed titles.
"""
first = title_numbers[0] if title_numbers else 1
raw_scan = tuple(self.ScanTitle(first))
title_count = FindTitleCount(raw_scan, verbose)
print('Disc claims to have %d titles.' % title_count)
title_name, title_info = only(
ParseTitleScan(ExtractTitleScan(raw_scan)).items())
del raw_scan
def MakeTitle(name, number, info):
assert ('title %d' % number) == name
info['duration'] = ExtractDuration('duration ' + info['duration'])
return Title(number, info)
yield MakeTitle(title_name, first, title_info)
to_scan = [x for x in range(1, title_count + 1)
if x != first
and ((not title_numbers)
or x in title_numbers)]
for i in to_scan:
try:
scan = ExtractTitleScan(self.ScanTitle(i))
except subprocess.CalledProcessError as exc:
warn("Cannot scan title %d." % i)
else:
title_info_names = ParseTitleScan(scan).items()
if title_info_names:
title_name, title_info = only(title_info_names)
yield MakeTitle(title_name, i, title_info)
else:
warn("Cannot parse scan of title %d." % i)
def Eject(self):
# TODO: this should really be a while loop that terminates once a
# deadline is met.
for i in range(TOTAL_EJECT_SECONDS * EJECT_ATTEMPTS_PER_SECOND):
if not subprocess.call(['eject', self.mountpoint]):
return
time.sleep(1.0 / EJECT_ATTEMPTS_PER_SECOND)
def ParseDuration(s):
result = 0
for field in s.strip().split(':'):
result *= 60
result += int(field)
return result
def FindMountPoint(dev, timeout):
regex = re.compile(r'^' + re.escape(os.path.realpath(dev)) + r'\b')
now = time.time()
end_time = now + timeout
while end_time >= now:
for line in check_output(['df', '-P']).split('\n'):
m = regex.match(line)
if m:
line = line.split(None, 5)
if len(line) > 1:
return line[-1]
time.sleep(0.1)
now = time.time()
raise UserError('%r not mounted.' % dev)
def FindMainFeature(titles, verbose=False):
if verbose:
print('Attempting to determine main feature of %d titles...'
% len(titles))
main_feature = max(titles,
key=lambda title: ParseDuration(title.info['duration']))
if verbose:
print('Selected %r as main feature.' % main_feature.number)
print()
def ConstructTasks(titles, chapter_split):
for title in titles:
num_chapters = len(title.info['chapters'])
if chapter_split and num_chapters > 1:
for chapter in range(1, num_chapters + 1):
yield Task(title, chapter)
else:
yield Task(title, None)
def TaskFilenames(tasks, output, dry_run=False):
if (len(tasks) > 1):
def ComputeFileName(task):
if task.chapter is None:
return os.path.join(output,
'Title%02d.mkv' % task.title.number)
else:
return os.path.join(output,
'Title%02d_%02d.mkv'
% (task.title.number, task.chapter))
if not dry_run:
os.makedirs(output)
else:
def ComputeFileName(task):
return '%s.mkv' % output
result = [ComputeFileName(task) for task in tasks]
if len(set(result)) != len(result):
raise UserError("multiple tasks use same filename")
return result
def PerformTasks(dvd, tasks, title_count, filenames,
dry_run=False, verbose=False):
for task, filename in zip(tasks, filenames):
print('=' * 78)
if task.chapter is None:
print('Title %s / %s => %r'
% (task.title.number, title_count, filename))
else:
num_chapters = len(task.title.info['chapters'])
print('Title %s / %s , Chapter %s / %s=> %r'
% (task.title.number, title_count, task.chapter,
num_chapters, filename))
print('-' * 78)
dvd.RipTitle(task, filename, dry_run, verbose)
Size = namedtuple('Size',
['width', 'height', 'pix_aspect_width', 'pix_aspect_height', 'fps'])
SIZE_REGEX = re.compile(
r'^\s*(\d+)x(\d+),\s*'
r'pixel aspect: (\d+)/(\d+),\s*'
r'display aspect: (?:\d+(?:\.\d+)),\s*'
r'(\d+(?:\.\d+)) fps\s*$')
SIZE_CTORS = [int] * 4 + [float]
def ParseSize(s):
return Size(*(f(x)
for f, x in zip(SIZE_CTORS, SIZE_REGEX.match(s).groups())))
def ComputeAspectRatio(size):
w = size.width * size.pix_aspect_width
h = size.height * size.pix_aspect_height
d = gcd(w, h)
return (w // d, h // d)
DURATION_REGEX = re.compile(
r'^(?:.*,)?\s*duration\s+(\d\d):(\d\d):(\d\d)\s*(?:,.*)?$')
class Duration(namedtuple('Duration', 'hours minutes seconds')):
def __str__(self):
return '%02d:%02d:%02d' % (self)
def in_seconds(self):
return 60 * (60 * self.hours + self.minutes) + self.seconds
def ExtractDuration(s):
return Duration(*map(int, DURATION_REGEX.match(s).groups()))
Chapter = namedtuple('Chapter', 'number duration')
def ParseChapters(d):
"""
Parses dictionary of (str) chapter numbers to chapter.
Result will be an iterable of Chapter objects, sorted by number.
"""
for number, info in sorted(((int(n), info) for (n, info) in d.items())):
yield Chapter(number, ExtractDuration(info))
AUDIO_TRACK_REGEX = re.compile(
r'^(\S+)\s*((?:\([^)]*\)\s*)*)(?:,\s*(.*))?$')
AUDIO_TRACK_FIELD_REGEX = re.compile(
r'^\(([^)]*)\)\s*\(([^)]*?)\s*ch\)\s*' +
r'((?:\([^()]*\)\s*)*)\(iso639-2:\s*([^)]+)\)$')
AudioTrack = namedtuple('AudioTrack',
'number lang codec channels iso639_2 extras')
def ParseAudioTracks(d):
for number, info in sorted(((int(n), info) for (n, info) in d.items())):
m = AUDIO_TRACK_REGEX.match(info)
if m:
lang, field_string, extras = m.groups()
m2 = AUDIO_TRACK_FIELD_REGEX.match(field_string)
if m2:
codec, channels, more_extras, iso639_2 = m2.groups()
if more_extras:
extras = more_extras + extras
yield AudioTrack(number, lang, codec, channels,
iso639_2, extras)
else:
warn('Cannot parse audio track fields %r' % field_string)
else:
warn('Cannot parse audio track info %r' % info)
SUB_TRACK_REGEX = re.compile(
r'^(\S(?:.*\S)?)\s+\(iso639-2:\s*([^)]+)\)\s*((?:\S(?:.*\S)?)?)$')
SubtitleTrack = namedtuple('SubtitleTrack',
'number name iso639_2 extras')
def ParseSubtitleTracks(d):
for number, info in sorted(((int(n), info) for (n, info) in d.items())):
m = SUB_TRACK_REGEX.match(info)
if( m ):
assert m, 'UNMATCHED %r' % info
name, iso639_2, extras = m.groups()
yield SubtitleTrack(number, name, iso639_2, extras)
else:
yield SubtitleTrack(number, info, '', '')
def RenderBar(start, length, total, width):
end = start + length
start = int(round(start * (width - 1) / total))
length = int(round(end * (width - 1) / total)) - start + 1
return ('' * start +
'' * length +
'' * (width - start - length))
MAX_BAR_WIDTH = 50
def DisplayScan(titles):
max_title_seconds = max(
title.info['duration'].in_seconds()
for title in titles)
for title in titles:
info = title.info
size = ParseSize(info['size'])
xaspect, yaspect = ComputeAspectRatio(size)
duration = info['duration']
title_seconds = duration.in_seconds()
print('Title % 3d/% 3d: %s %d×%d %d:%d %3g fps' %
(title.number, len(titles), duration, size.width,
size.height, xaspect, yaspect, size.fps))
for at in ParseAudioTracks(info['audio tracks']):
print(' audio % 3d: %s (%sch) [%s]' %
(at.number, at.lang, at.channels, at.extras))
for sub in ParseSubtitleTracks(info['subtitle tracks']):
print(' sub % 3d: %s [%s]' %
(sub.number, sub.name, sub.extras))
position = 0
if title_seconds > 0:
for chapter in ParseChapters(info['chapters']):
seconds = chapter.duration.in_seconds()
bar_width = int(round(
MAX_BAR_WIDTH * title_seconds / max_title_seconds))
bar = RenderBar(position, seconds, title_seconds, bar_width)
print(' chapter % 3d: %s%s'
% (chapter.number, chapter.duration, bar))
position += seconds
print()
def ParseArgs():
description, epilog = __doc__.strip().split('\n', 1)
parser = argparse.ArgumentParser(description=description, epilog=epilog,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('-v', '--verbose',
action='store_true',
help="Increase verbosity.")
parser.add_argument('-c', '--chapter_split',
action='store_true',
help="Split each chapter out into a separate file.")
parser.add_argument('-n', '--dry-run',
action='store_true',
help="Don't actually write anything.")
parser.add_argument('--scan',
action='store_true',
help="Display scan of disc; do not rip.")
parser.add_argument('--main-feature',
action='store_true',
help="Rip only the main feature title.")
parser.add_argument('-t', '--titles',
default="*",
help="""Comma-separated list of title numbers to consider
(starting at 1) or * for all titles.""")
parser.add_argument('-i', '--input',
help="Volume to rip (must be a directory).")
parser.add_argument('-o', '--output',
help="""Output location. Extension is added if only one title
being ripped, otherwise, a directory will be created to contain
ripped titles.""")
parser.add_argument('--mount-timeout',
default=15,
help="Amount of time to wait for a mountpoint to be mounted",
type=float)
args = parser.parse_args()
if not args.scan and args.output is None:
raise UserError("output argument is required")
return args
# TODO: make it possible to have ranges with no end (meaning they end at last
# title)
NUM_RANGE_REGEX = re.compile(r'^(\d*)-(\d+)|(\d+)$')
def parse_titles_arg(titles_arg):
if titles_arg == '*':
return None # all titles
else:
def str_to_ints(s):
m = NUM_RANGE_REGEX.match(s)
if not m :
raise UserError(
"--titles must be * or list of integer ranges, found %r" %
titles_arg)
else:
start,end,only = m.groups()
if only is not None:
return [int(only)]
else:
start = int(start) if start else 1
end = int(end)
return range(start, end + 1)
result = set()
for s in titles_arg.split(','):
result.update(str_to_ints(s))
result = sorted(list(result))
return result
def main():
args = ParseArgs()
dvd = DVD(args.input, args.verbose, args.mount_timeout)
print('Reading from %r' % dvd.mountpoint)
title_numbers = parse_titles_arg(args.titles)
titles = tuple(dvd.ScanTitles(title_numbers, args.verbose))
if args.scan:
DisplayScan(titles)
else:
if args.main_feature and len(titles) > 1:
# TODO: make this affect scan as well
titles = [FindMainFeature(titles, args.verbose)]
if not titles:
raise UserError("No titles to rip")
else:
if not args.output:
raise UserError("No output specified")
print('Writing to %r' % args.output)
tasks = tuple(ConstructTasks(titles, args.chapter_split))
filenames = TaskFilenames(tasks, args.output, dry_run=args.dry_run)
# Don't stomp on existing files
for filename in filenames:
if os.path.exists(filename):
raise UserError('%r already exists' % filename)
PerformTasks(dvd, tasks, len(titles), filenames,
dry_run=args.dry_run, verbose=args.verbose)
print('=' * 78)
if not args.dry_run:
dvd.Eject()
def warn(msg):
print('warning: %s' % (msg,), file=sys.stderr)
if __name__ == '__main__':
error = None
try:
main()
except FileExistsError as exc:
error = '%s: %r' % (exc.strerror, exc.filename)
except UserError as exc:
error = exc.message
if error is not None:
print('%s: error: %s'
% (os.path.basename(sys.argv[0]), error), file=sys.stderr)
sys.exit(1)