#!/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)