diff --git a/bash_profile b/bash_profile index ddc85434..be63bcd1 100644 --- a/bash_profile +++ b/bash_profile @@ -25,3 +25,5 @@ complete -o default -o nospace -W "$(/usr/bin/env ruby -ne 'puts $_.split(/[,\s] #echo -e "Please don't sabotage my computer while I'm away \n\nTo turn on the print server, run the command 'cups'\n\nTo update 3d party plugins, run command 'u3p'" | /usr/bin/cowsay -f tux -W 80 + +[ -r "$HOME/.smartcd_config" ] && ( [ -n $BASH_VERSION ] || [ -n $ZSH_VERSION ] ) && source ~/.smartcd_config diff --git a/bin/dvdrip b/bin/dvdrip new file mode 100755 index 00000000..36679b51 --- /dev/null +++ b/bin/dvdrip @@ -0,0 +1,696 @@ +#!/usr/bin/env python3 +# coding=utf-8 + +""" +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) + + audio_tracks = task.title.info['audio tracks'].keys() + audio_encoders = ['faac'] * len(audio_tracks) + subtitles = task.title.info['subtitle tracks'].keys() + + args = [ + HANDBRAKE, + '--title', str(task.title.number), + '--preset', "High Profile", + '--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) + 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) + assert m, 'UNMATCHED %r' % info + name, iso639_2, extras = m.groups() + yield SubtitleTrack(number, name, iso639_2, extras) + +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)