#!/usr/bin/env python
#
# Copyright (c) 2008, Chris Jones
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the
#    distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""CLI MPlayer/MEncoder front-end"""

from __future__ import division, with_statement
from collections import defaultdict
from optparse import OptionParser
from operator import itemgetter
from select import select
import logging as log
from math import sqrt
import codecs
import shlex
import errno
import time
import sys
import pty
import tty
import os
import re

__version__ = '0.2'
__author__ = 'Chris Jones <cjones@gruntle.org>'
__all__ = ['VidConvert']

USAGE = '%prog [OPTION] ... <FILE|DIR> ...'
LOG = {'level': log.INFO, 'stream': sys.stderr, 'datefmt': '%x %X',
       'format': '[%(asctime)s] %(levelname)s: %(message)s'}

PRESETS = {'movie': {'nosound': False, '_channels': 2, 'namekey': 'movie',
                     'size': '700m', 'normalize': True, 'passes': 1,
                     'srate': 44100, 'rescale': None, '_vbitrate': None,
                     '_abitrate': 128},
           'tv': {'nosound': False, '_channels': 2, 'namekey': 'tv',
                  'size': '350m', 'normalize': True, 'passes': 1,
                  'srate': 44100, 'rescale': None, '_vbitrate': None,
                  '_abitrate': 64},
           'ipod': {'nosound': False, '_channels': 2, 'mono': False,
                    'namekey': 'ipod', 'size': '175m', 'normalize': True,
                    'passes': 1, 'srate': 22050, 'rescale': 5.0,
                    '_vbitrate': None, '_abitrate': 32}}

CHILD = 0
PIPE = -1
STDOUT = -2
BUFSIZE = 1024

try:
    ENCODING = codecs.lookup(sys.stdin.encoding).name
except (AttributeError, TypeError, LookupError):
    ENCODING = sys.getdefaultencoding()

try:
    MAXFDS = os.sysconf('SC_OPEN_MAX')
except ValueError:
    MAXFDS = None

class _Base(object):

    def __repr__(self):
        attrs = ', '.join(sorted('%s=%r' % i for i in self.__dict__.items()
                                 if i[0][0] != '_' if i[1] is not None))
        return '<%s%s%s>' % (type(self).__name__, ': ' if attrs else '', attrs)


class RawTTY(_Base):

    def __init__(self, fd):
        self.fd = fd

    def __enter__(self):
        self.attr = tty.tcgetattr(self.fd)
        tty.setraw(self.fd)
        return self

    def __exit__(self, *args):
        tty.tcsetattr(self.fd, tty.TCSADRAIN, self.attr)


class _Stream(_Base):

    rev_mode_map = {'r': 'w', 'w': 'r'}

    def __init__(self, key, fd):
        old = getattr(sys, key)
        self.old_fd = old.fileno()
        self.mode = old.mode, self.rev_mode_map[old.mode]
        if hasattr(fd, 'fileno'):
            fd = fd.fileno()
        self.fd = fd
        self.ispipe = False
        self.isstdout = False
        self.isfd = False
        if fd == PIPE:
            self.ispipe = True
        elif fd == STDOUT:
            self.isstdout = True
        elif isinstance(fd, int):
            self.isfd = True
        self.fp = None

    def __getitem__(self, key):
        return getattr(self, key)


class _Streams(_Base):

    keys = 'stdin', 'stdout', 'stderr'

    def __init__(self, **kwargs):
        self.streams = dict((key, _Stream(key, kwargs[key]))
                            for key in self.keys)

    def open_prefork(self):
        for stream in self:
            if stream.ispipe:
                stream.r, stream.w = os.pipe()

    def open_parent(self):
        for stream in self:
            cmode, omode = stream.mode
            if stream.ispipe:
                ofd, cfd = stream[omode], stream[cmode]
                os.close(cfd)
                stream.fp = os.fdopen(ofd, omode)

    def open_child(self):
        for stream in self:
            omode, cmode = stream.mode
            if stream.ispipe:
                ofd, cfd = stream[omode], stream[cmode]
                os.close(cfd)
            elif stream.isstdout:
                stdout = self.streams['stdout']
                if stdout.ispipe:
                    ofd = stdout[omode]
                elif stdout.isfd:
                    ofd = stdout.fd
                else:
                    ofd = stdout.old_fd
            elif stream.isfd:
                ofd = stream.fd
            else:
                continue
            os.dup2(ofd, stream.old_fd)

    def close(self):
        for stream in self:
            if stream.ispipe:
                pass
            elif stream.isstdout:
                pass
            elif stream.isfd:
                pass
            else:
                pass

    def __iter__(self):
        for key in self.keys:
            yield self.streams[key]

    def __getattr__(self, key):
        return self.streams[key].fp


class RunPTY(_Base):

    def __init__(self, cmd, args=None, dir=None, pty=True, env=None,
                 stdin=None, stdout=None, stderr=None, timeout=None,
                 auto_open=True, auto_close=True, closefds=True, maxfds=None,
                 encoding=None, decode=True, bufsize=None):
        if encoding is None:
            encoding = ENCODING
        if maxfds is None:
            maxfds = MAXFDS
        if bufsize is None:
            bufsize = BUFSIZE
        self.encoding = encoding
        self.args = self._parse_args(cmd) + self._parse_args(args)
        self.dir = dir
        self.pty = pty
        self.env = env
        self._streams = _Streams(stdin=stdin, stdout=stdout, stderr=stderr)
        self.timeout = timeout
        self.auto_open = auto_open
        self.auto_close = auto_close
        self.closefds = closefds
        self.maxfds = maxfds
        self.decode = decode
        self.bufsize = bufsize
        self.opened = False
        self.status = None
        self.pid = None
        self.fd = None
        if auto_open:
            self.open()

    def open(self):
        if self.opened:
            if not self.auto_close:
                return
            self.close()
        self._streams.open_prefork()
        if self.pty:
            self.pid, self.fd = pty.fork()
        else:
            self.pid = os.fork()
        if self.pid == CHILD:
            self._streams.open_child()
            if self.closefds and self.maxfds:
                for fd in xrange(3, self.maxfds):
                    try:
                        os.close(fd)
                    except OSError, error:
                        if error.errno != errno.EBADF:
                            raise
            if self.dir:
                os.chdir(self.dir)
            if self.env is None:
                os.execvp(self.cmd, self.args)
            else:
                os.execvpe(self.cmd, self.args, self.env)
            os._exit(0)
        self._streams.open_parent()
        self.opened = True
        self.status = None
        return self.pid

    def wait(self):
        if self.opened and self.status is None:
            self.status = os.waitpid(self.pid, 0)[1]
        return self.status

    def close(self):
        if self.opened:
            self._streams.close()
            if self.pty:
                os.close(self.fd)
            self.wait()
            self.opened = False
            self.pid = None
            self.fd = None
        return self.status

    def fileno(self, mode='r'):
        if mode == 'r':
            if self.stdout:
                return self.stdout.fileno()
            if self.stderr:
                return self.stderr.fileno()
        elif mode == 'w' and self.stdin:
            return self.stdin.fileno()
        return self.fd

    def isatty(self):
        return self.fileno('r') == self.fd

    def recv(self, size):
        data = ''
        if self.opened:
            fd = self.fileno(mode='r')
            if fd is not None and fd in select([fd], [], [], self.timeout)[0]:
                try:
                    data = os.read(fd, size)
                except OSError, error:
                    if error.errno != errno.EIO:
                        raise
            if not data and self.auto_close:
                self.close()
        if self.decode:
            data = data.decode(self.encoding)
        return data

    def send(self, data):
        size = 0
        if self.opened:
            fd = self.fileno(mode='w')
            if fd is not None and fd in select([], [fd], [], self.timeout)[1]:
                if isinstance(data, unicode):
                    data = data.encode(self.encoding)
                try:
                    size = os.write(fd, data)
                except OSError, error:
                    if error.errno != errno.EIO:
                        raise
            if not size and self.auto_close:
                self.close()
        return size

    def read(self, size=None):
        if size is None:
            buf = []
            while self.opened:
                data = self.recv(self.bufsize)
                if not data:
                    break
                buf.append(data)
            return ''.join(buf)
        return self.recv(size)

    def write(self, data):
        return self.send(data)

    def readline(self, newline='\n'):
        buf = []
        while self.opened:
            data = self.read(1)
            if not data:
                break
            buf.append(data)
            if data == newline:
                break
        return ''.join(buf)

    def writeline(self, data):
        buf = list(data)
        while buf and buf[-1] in ('\r', '\n'):
            buf.pop()
        if self.isatty():
            buf.append('\r')
        else:
            buf.append('\n')
        return self.write(''.join(buf))

    def readlines(self):
        return self.read().splitlines()

    def writelines(self, data):
        if isinstance(data, basestring):
            data = data.splitlines()
        buf = 0
        for line in data:
            size = self.writeline(line)
            if not size:
                break
            buf += size
        return buf

    def attach(self, detach='\x1a', log=None, keylog=None):
        if not self.pty:
            return
        stdin = sys.stdin.fileno()
        pty = self.fd
        rfds = [stdin, pty]
        attached = True
        with RawTTY(stdin):
            while self.opened and attached:
                for fd in select(rfds, [], [], None)[0]:
                    data = None
                    try:
                        data = os.read(fd, self.bufsize)
                    except OSError, error:
                        if error.errno != errno.EIO:
                            raise
                    if not data:
                        attached = False
                        if self.auto_close:
                            self.close()
                        break
                    if fd == stdin:
                        self.write(data)
                        if keylog:
                            keylog.write(data)
                            if hasattr(keylog, 'flush'):
                                keylog.flush()
                    elif fd == pty:
                        sys.stdout.write(data)
                        sys.stdout.flush()
                        if log:
                            log.write(data)
                            if hasattr(log, 'flush'):
                                log.flush()

    @property
    def closed(self):
        return not self.opened

    @property
    def cmd(self):
        return self.args[0]

    @property
    def stdin(self):
        return self._streams.stdin

    @property
    def stdout(self):
        return self._streams.stdout

    @property
    def stderr(self):
        return self._streams.stderr

    def _parse_args(self, args):
        if args is None:
            return []
        if isinstance(args, basestring):
            if isinstance(args, unicode):
                args = args.encode(self.encoding)
            return shlex.split(args)
        return list(args)

    def __del__(self):
        try:
            self.close()
        except:
            pass

    def __iter__(self):
        while self.opened:
            data = self.readline()
            if not data:
                break
            yield data


def cache(method):

    """Caching property decorator"""

    __cache = {}

    def inner_method(self, *args, **kwargs):
        """Only refresh cache if object non-private attributes change"""
        key = tuple(
                self.__dict__[k] for k in sorted(self.__dict__)
                if not k.startswith('__'))
        if key not in __cache:
            log.debug('calculating value for property: %s' % method.func_name)
            __cache[key] = method(self, *args, **kwargs)
            log.debug('the value for prop %s is %r' % (method.func_name,
                __cache[key]))
        return __cache[key]

    return property(inner_method)


class VidConvertError(Exception):

    """Base VidConvert exception"""


class MPlayerError(VidConvertError):

    """Raised when MPlayer/Mencoder exits with >0 status"""


class InvalidMedia(VidConvertError):

    """Raised when media can't be identified or doesn't exist"""


class UnknownParameter(VidConvertError):

    """Raised when a needed video parameter can't be found"""


class InvalidParameter(VidConvertError):

    """Raised when an invalid parameter is provided"""


class VidConvert(object):

    """CLI MPlayer/MEncoder front-end"""

    # defaults
    _default_title = 1
    _default_channels = 2
    _default_rate = 44100
    _default_scantime = (6, 60)  # 6 samples for a total of 60 seconds

    # constants
    _units = 'bkmgt'
    _media = {'cdr': 702, 'dvd': 4472, 'dldvd': 4472 * 2}
    _bufsize = 65536
    _update_time = 0.25
    _time_units = (('second', 60), ('minute', 60), ('hour', 24), ('day', 7),
                   ('week', 4), ('month', 12), ('year', 0))
    _status_fmt = 'Progress: %.1f%% | Elapsed: %s | Time Left: %s            \r'
    _hide_cursor = '\x1b[?25l'
    _show_cursor = '\x1b[?25h'

    # arguments for various internal commands
    _identify_args = '-quiet -vo null -nosound -identify -endpos 0'.split()
    _scan_args = '-quiet -ovc copy -o'.split() + [os.devnull]
    _cropdetect_args = '-ovc raw -nosound -vf cropdetect -o'.split()
    _cropdetect_args.append(os.devnull)
    _subtitle_args = ('-subfont-encoding', 'unicode', '-utf8',
                      '-ffactor', '10', '-subpos', '100', '-subalign', '2',
                      '-subfont-autoscale', '2', '-subfont-blur', '1.1',
                      '-subfont-outline', '1.2', '-subfont-text-scale', '3',
                      '-subfont-osd-scale', '4.2')

    # pre-compiled regular expressions
    _identify_re = re.compile(r'^ID_(.+?)=(.+)$')
    _stream_re = re.compile(r'^(Video|Audio) stream:\s+([0-9.]+)\s+kbit/s.*?s'
                            r'ize:\s+(\d+)\s+bytes\s+([0-9.]+)\s+secs')
    _video_re = re.compile(r'^videocodec:.*?(\d+)x(\d+)')
    _audio_re = re.compile(r'^audiocodec:.*?chans=(\d+)\s+rate=(\d+)')
    _cropdetect_re = re.compile(r'crop=(\d+:\d+:\d+:\d+)')
    _title_length_re = re.compile(r'^dvd_title_(\d+)_length$')
    _exact_size_re = re.compile(r'^([0-9.]+)([%s])$' % _units, re.I)
    _media_size_re = re.compile(r'^([0-9.]+)/(%s)$' % '|'.join(_media), re.I)
    _pos_re = re.compile(r'Pos:\s*([1-9][0-9]*\.[0-9]+)')

    # format options
    _video_options = ('-ofps', '24000/1001')

    _formats = {
            'divx': {
                'ext': 'avi',
                'video_options': ('-ffourcc', 'DX50'),
                'audio_options': lambda t,br: (
                    '-oac', 'mp3lame', '-lameopts', '%s:br=%d' % (t,br)),
                'vbr_options': ('-oac', 'mp3lame', '-lameopts',
                                'vbr=4:q=5:aq=2'),
                'vcodec': 'mpeg4',
                'hiq': 'v4mv',
                },

            'dvd': {
                'ext': 'mpg',
                'video_options': (
                    '-of', 'mpeg', '-mpegopts', 'format=dvd:tsaf'),
                'audio_options': lambda t,br: (
                    '-oac', 'lavc', '-lavcopts', 'acodec=ac3:abitrate=%d' % br),
                'vcodec': 'mpeg2video',
                'hiq': 'mbd=2',
                'lavc': ('vrc_buf_size=1835:keyint=18:precmp=2:subcmp=2:cmp=2'
                         ':dia=-10:predia=-10:cbp:mv0:vqmin=1:lmin=1:dc=10'
                         ':trell')
                },

            'mpg': {
                'ext': 'mpg',
                'video_options': (
                    '-of', 'mpeg', '-mpegopts',
                    'format=mpeg1:tsaf:muxrate=2000'),
                'audio_options': lambda t,br: (
                    '-oac', 'lavc', '-lavcopts', 'acodec=mp2:abitrate=%d' % br),
                'vcodec': 'mpeg1video',
                'hiq': 'mbd=2:trell',
                'lavc': 'keyint=15'
                },
            }

    def __init__(self, file, **opts):
        self.file = file
        self.__dict__.update(opts)
        self.__fullscan_done = False

    # actions

    def identify(self):
        """Show video parameters"""
        for name in 'id', 'scan', 'fullscan':
            id = getattr(self, name)
            for key in sorted(id):
                print '%s: %s' % (key, id[key])

    def play(self):
        """Play video"""
        for line in self.mplayer('-quiet', self.section):
            log.debug(line)

    def encode(self):
        """Encode video"""

        if self.quality == 'high':
            twopass = (self.passes == 2)
        else:
            twopass = False
            self.passes = 1

        passlog_file = self.passlog_file
        if self.generate:
            passlog_file = self.quote(passlog_file)

        # initialize shell script
        if self.generate:
            shell = ['#!/bin/sh', '']
            rm = 'rm -f %s' % passlog_file if twopass else ''
            if rm:
                shell += [rm, '']

        for passnum in range(1, self.passes + 1):
            firstpass = (twopass and passnum == 1)

            # output options
            output = os.devnull if firstpass else self.output_file
            if self.generate:
                output = self.quote(output)

            # audio options
            if self.nosound:
                audio_options = ['-nosound']
            elif firstpass:
                audio_options = '-oac', 'copy'
            else:
                audio_options = []
                if self._usevbr and self._format == 'divx':
                    audio_options.append(self.format['vbr_options'])
                else:
                    t = 'cbr' if self.size else 'abr'
                    opt = self.format['audio_options'](t,self.abitrate)
                    audio_options.append(opt)
                audio_options += ['-channels', self.channels]
                if self.quality == 'high':
                    audio_options += ['-srate', self.srate]
                audio_options.append(self.audio_filter)

            # video options
            video_options = [self.format['video_options'],
                             self.video_filter,
                             '-ovc', 'lavc',
                             self.video_lavcopts]
            if self.quality == 'high':
                video_options.append(self._video_options)

            subtitle_options = []
            if self.hardsub:
                if self.generate:
                    hardsub = self.quote(self.hardsub)
                else:
                    hardsub = self.hardsub
                subtitle_options += ['-sub', hardsub]
                subtitle_options += list(self._subtitle_args)

            # two-pass specific args
            if twopass:
                passopts = ['vpass=%d' % passnum]
                if firstpass:
                    passopts.append('turbo')
                passargs = ('-passlogfile', passlog_file,
                            '-lavcopts', ':'.join(passopts))
            else:
                passargs = None

            # build command
            args = ('mencoder',
                    self.get_source(shell=self.generate),
                    self.section,
                    '-o', output,
                    audio_options,
                    video_options,
                    subtitle_options,
                    passargs,
                    '-quiet' if self.generate else None)

            # run encoder command or add to shell script
            if self.generate:
                if twopass:
                    shell.append('# pass %d' % passnum)
                shell += self.mkshell(args)
                shell.append('')
            else:
                status = 'encoding video'
                if twopass:
                    status += ': pass %d' % passnum
                log.info(status)

                try:
                    sys.stderr.write(self._hide_cursor)
                    start = time.time()
                    buf = []
                    last_update = 0
                    for data in self.runpty(args, readline=False, show=True,
                                            newline='\r'):
                        data = data.replace('\n', '')
                        buf.append(data)
                        now = time.time()
                        if now - last_update < self._update_time:
                            continue
                        last_update = now
                        data = ''.join(buf)
                        buf = []
                        try:
                            pos = float(self._pos_re.search(data).group(1))
                        except AttributeError:
                            continue

                        # display progress bar
                        elapsed = now - start
                        sys.stderr.write(self._status_fmt % (
                            (pos / self.length * 100), self.clock(elapsed),
                            self.clock(elapsed * (self.length - pos) / pos)))

                finally:
                    sys.stderr.write((' ' * 70) + self._show_cursor + '\r')
                log.info('elapsed: %.2f seconds' % (time.time() - start))

        # write shell script
        if self.generate:
            if rm:
                shell += [rm, '']
            if self.shell_file == '-':
                file = sys.stdout
            else:
                file = open(self.shell_file, 'ab')
            try:
                file.write('\n'.join(shell) + '\n')
            finally:
                if self.shell_file != '-':
                    file.close()
                    os.chmod(self.shell_file, 0755)
                    log.info('wrote to ' + self.shell_file)
        elif twopass and os.path.exists(passlog_file):
            os.remove(passlog_file)

        # some extra stuff logged at the end for the benefit of a build script
        # that does audio encoding seperate and remuxes
        # if self.nosound:
        #     log.debug('length: %s' % self.length)
        #     self.dbgain
        #     log.debug('length: %s' % self.length)

    # video source parameters

    @cache
    def type(self):
        """Type of media: dvd or file"""
        if self.file is None or os.path.isdir(self.file):
            return 'dvd'
        elif os.path.isfile(self.file):
            return 'file'
        else:
            raise InvalidMedia('unknown media type')

    @cache
    def title(self):
        """DVD Title to play"""
        if self._title:
            return self._title
        return self._default_title

    def get_source(self, shell=False):
        """Source arguments"""
        source = []
        if self.type == 'dvd' and self.file:
            source.append('-dvd-device')
        if self.file:
            file = self.file
            if shell:
                file = self.quote(file)
            source.append(file)
        if self.type == 'dvd':
            source.append('dvd://%d' % self.title)
        if self.aid is not None:
            source.append({'-aid': self.aid})
        if self.sid is not None:
            source.append({'-sid': self.sid})
        return source

    source = cache(get_source)

    # scanned video identity parameters

    @cache
    def id(self):
        """Parameters from -identify"""
        log.info('identifying media')
        id = {}
        for line in self.mplayer(self._identify_args):
            try:
                key, val = self._identify_re.search(line).groups()
            except AttributeError:
                continue
            id[key.lower()] = self.cast(val)

        if not id:
            raise InvalidMedia('no media found')

        # auto-detect main feature
        if self.type == 'dvd' and not self._title:
            lengths = defaultdict(list)
            for key, val in id.items():
                try:
                    title = self._title_length_re.search(key).group(1)
                except AttributeError:
                    continue
                lengths[val].append(int(title))

            if lengths:
                title = sorted(max(lengths.iteritems(),
                                   key=lambda item: item[0])[1])[0]
                if title != self._default_title:
                    self._title = title
                    log.info('detected main feature: %d' % title)

                    # must rescan new title parameters
                    id = self.id

        return id

    def sample(self, samples, size):
        """Generate position arguments"""
        step = self.length / samples
        ss = 0
        endpos = int(size / samples)
        sampled = 0
        for i in xrange(samples):
            yield {'-ss': ss, '-endpos': endpos}
            ss += int(step)
            sampled += endpos
            if ss >= self.length or sampled >= size:
                break

    @cache
    def oac(self):
        args = [self._scan_args, '-ss', 0, '-endpos', 1, '-oac']
        for test in 'copy', 'pcm':
            log.debug('testing oac %s' % test)
            try:
                list(self.mencoder(args, test))
                return '-oac', test
            except MPlayerError, error:
                log.info('oac %s failed' % test)
        raise

    @cache
    def scan(self):
        """Parameters from sampled scan"""
        if not self.scantime[0]:
            return {}
        log.info('scanning media')
        id = {}
        bitrates = defaultdict(list)
        filters = defaultdict(int)

        for pos in self.sample(*self.scantime):

            # scan for bitrates / format-specific parameters
            for line in self.mencoder(self._scan_args, self.oac, pos):
                try:
                    match = list(self._stream_re.search(line).groups())
                    stream, bitrate = map(self.cast, match[:2])
                    bitrates[stream].append(bitrate)
                    continue
                except AttributeError:
                    pass

                try:
                    match = self._video_re.search(line).groups()
                    id['video_width'], id['video_height'] = map(
                            self.cast, match)
                    continue
                except AttributeError:
                    pass

                try:
                    match = self._audio_re.search(line).groups()
                    id['audio_nch'], id['audio_rate'] = map(self.cast, match)
                except AttributeError:
                    pass

            # scan for crop filter
            for line in self.mencoder(self._cropdetect_args, pos):
                try:
                    filter = self._cropdetect_re.search(line).group(1)
                except AttributeError:
                    continue
                filters[filter] += 1

        # average sampled bitrates
        for stream, stream_bitrates in bitrates.items():
            avg = sum(stream_bitrates) / len(stream_bitrates)
            id[stream.lower() + '_bitrate'] = avg * 1000

        # find most-likely crop filter
        if filters:
            filters = sorted(
                    filters.iteritems(), key=itemgetter(1), reverse=True)
            filter = filters[0][0]
            if not filter.endswith(':0:0'):
                id['crop'] = filter

        if not id:
            raise InvalidMedia('no media found')

        return id

    @cache
    def fullscan(self):
        """Full scan of media as last resort"""
        log.info('full scanning media')
        id = {}
        for line in self.mencoder(self._scan_args, self.oac):
            try:
                match = list(self._stream_re.search(line).groups())
                stream, bitrate, size, length = map(self.cast, match)
                stream = stream.lower()
                id['length'] = length
                id[stream + '_size'] = size
                id[stream + '_bitrate'] = bitrate * 1000
                continue
            except AttributeError:
                pass

            try:
                match = self._video_re.search(line).groups()
                id['video_width'], id['video_height'] = map(
                        self.cast, match)
                continue
            except AttributeError:
                pass

            try:
                match = self._audio_re.search(line).groups()
                id['audio_nch'], id['audio_rate'] = map(self.cast, match)
            except AttributeError:
                pass

        self.__fullscan_done = True
        return id

    # video parameters derived from scans/id

    def getvalue(self, key, id=True, scan=True, fullscan=True):
        """Get value from video parameters"""

        # fullscanning is a last resort, but has the most accurate values,
        # so try it first without triggering a fullscan unless the value
        # can't be derived elsewhere.  full-scan will only occur if a
        # required value isn't derived elsewhere.
        if fullscan and self.__fullscan_done and key in self.fullscan:
            return self.fullscan[key]
        elif scan and key in self.scan:
            return self.scan[key]
        elif id and key in self.id:
            return self.id[key]
        elif fullscan and key in self.fullscan:
            return self.fullscan[key]
        else:
            if key == 'audio_bitrate':
                return 128
            raise UnknownParameter(key)

    @cache
    def length(self):
        """Length of media in seconds"""
        return self.getvalue('length', scan=False)

    @cache
    def vbitrate(self):
        """Video bitrate to encode to"""
        if self._vbitrate:
            bitrate = self._vbitrate
        elif self.size:
            try:
                size, unit = self._exact_size_re.search(self.size).groups()
                multiplier = 1000 ** self._units.index(unit.lower())
                size = self.cast(size) * multiplier / 1000000
            except AttributeError:
                try:
                    num, media = self._media_size_re.search(self.size).groups()
                    size = self._media[media] / self.cast(num)
                except AttributeError:
                    raise InvalidParameter('unknown size: ' + self.size)

            #bitrate = size - self.abitrate * self.length / 8000
            #bitrate = bitrate * 8220836 / self.length / 1000

            log.debug('TARGET SIZE IN MB: %s' % size)
            size = size * 1024 * 1024
            log.debug('TARGET SIZE IN B: %s' % size)
            fohead = 590 * self.length
            log.debug('FRAME OVERHEAD: %s' % fohead)
            mohead = 1000 * self.length
            log.debug('MUX OVERHEAD: %s' % mohead)
            sound_size = self.abitrate * self.length * 1000 / 8
            log.debug('EST AUDIO SIZE: %s' % sound_size)
            size = size - sound_size - fohead - mohead
            log.debug('ACTUAL TARGET SIZE: %s' % size)
            bitrate = int(size / self.length * 8 / 1000)
            log.debug('BITRATE: %s' % bitrate)

            if bitrate <= 0:
                raise InvalidParameter('size too small for media')
        else:
            bitrate = self.getvalue('video_bitrate') / 1000

        return bitrate

    @cache
    def dbgain(self):
        wav_file = self.wav_file
        args = ['-quiet',
                '-vc', 'null',
                '-vo', 'null',
                '-channels', 2,
                '-ao', 'pcm:fast:waveheader:file=%s' % wav_file]
        log.info('dumping WAV audio')
        list(self.mplayer(args))
        log.info('running wavgain')
        #gain_re = re.compile(r'\+([0-9.]+)\s*dB')
        gain_re = re.compile(r'([+-][0-9.]+)\s*dB')
        # -4.17 dB
        gain = None
        for line in self.wavegain('-p', '-x', wav_file):
            log.info(line)
            try:
                gain = gain_re.search(line).group(1)
            except AttributeError:
                pass
        if os.path.exists(wav_file):
            os.remove(wav_file)
        return gain

    @cache
    def audio_filter(self):
        filters = []
        if self.quality == 'high':
            filters.append('resample=%s:0:2' % self.srate)
        if self.normalize:
            dbgain = self.dbgain
            if dbgain:
                filters.append('volume=%s:0' % dbgain)
        if self.mono and self.channels > 1:
           filters.append('pan=' + ':'.join(['1'] + ['0.5'] * self.channels))

        if filters:
            return '-af', ','.join(filters)

    @cache
    def abitrate(self):
        """Audio bitrate to encode to"""
        if self._format == 'divx' and self._usevbr:
            return 132
        media_abitrate = self.getvalue('audio_bitrate') / 1000
        if self._abitrate:
            abitrate = self._abitrate
            if not self.aexact and abitrate > media_abitrate:
                abitrate = media_abitrate
        else:
            abitrate = media_abitrate
        if not self.aexact:
            abitrate = self.round(abitrate, 16)
        return abitrate

    @cache
    def channels(self):
        """Audio channels"""
        if self._channels:
            return self._channels
        try:
            return self.getvalue('audio_nch')
        except UnknownParameter:
            log.warn('using default audio channels')
            return self._default_channels

    @cache
    def rate(self):
        """Audio sample rate"""
        try:
            return self.getvalue('audio_rate')
        except UnknownParameter:
            log.warn('using default audio sample rate')
            return self._default_rate

    @cache
    def crop(self):
        """Crop setting"""
        try:
            return self.getvalue('crop', id=False, fullscan=False)
        except UnknownParameter:
            pass

    @cache
    def width(self):
        """Video width after cropping"""
        if self.crop:
            return int(self.crop.split(':')[0])
        return self.getvalue('video_width')

    @cache
    def height(self):
        """Video height after cropping"""
        if self.crop:
            return int(self.crop.split(':')[1])
        return self.getvalue('video_height')

    @cache
    def aspect(self):
        """Aspect ratio"""
        return self.width / self.height

    @cache
    def dvd_aspect(self):
        """4/3 or 16/9"""
        if self.aspect >= 1.5:
            return '16/9'
        return '4/3'

    # output files

    @cache
    def basename(self):
        if self._basename:
            name = self._basename
        elif self.file:
            name = self.file
        elif self.type == 'dvd':
            name = 'dvdrip'
        else:
            name = 'unknown'
        name = os.path.basename(name)
        name = os.path.splitext(name)[0]
        if self.namekey:
            name += '.' + self.namekey
        return name

    @cache
    def wav_file(self):
        """Wave dump file for dbgain calculation"""
        return self.newfile('audiodump-' + self.basename, 'wav')

    @cache
    def output_file(self):
        """Final output file"""
        return self.newfile(self.basename, self.format['ext'])

    @cache
    def passlog_file(self):
        """Temporary divx2pass log file"""
        return self.newfile('divx2pass-' + self.basename, 'log')

    @cache
    def shell_file(self):
        """Output file for generated shell script"""
        if self.output:
            return self.output
        return self.newfile('do-' + self.basename, 'sh')

    # output options based on format

    @cache
    def format(self):
        """Format options"""
        return self._formats[self._format]

    @cache
    def video_filter(self):
        """Video filter chain"""
        chain = []
        if self.interlaced and self.quality == 'high':
            chain += ['pullup', 'softskip']
        if self.crop:
            chain.append('crop=' + self.crop)
        if self.scale:
            chain += self.scale
        if self.quality == 'high':
            chain.append('hqdn3d=2:1:2')
        if self.format['ext'] == 'mpg':
            chain.append('harddup')
        if chain:
            return '-vf', ','.join(chain)

    @cache
    def video_lavcopts(self):
        """Options for lavc video encoder"""
        vbitrate = int(round(self.vbitrate))
        opts = ['vbitrate=%d' % vbitrate,
                'vcodec=' + self.format['vcodec'],
                'autoaspect']  # XXX 'aspect=' + self.dvd_aspect]
        if 'lavc' in self.format:
            opts.append(self.format['lavc'])
        if self.quality == 'high':
            opts.append(self.format['hiq'])
        if self._format == 'dvd':
            opts.append('vrc_maxrate=%d' % (vbitrate * 2))
        if self.threads:
            opts.append('threads=%d' % self.threads)
        if opts:
            return '-lavcopts', ':'.join(opts)

    @cache
    def scale(self):
        """Scale filter"""
        filter = []
        if self.dimensions:
            filter.append('scale=%d:-2' % self.dimensions[0])
            filter.append('expand=%d:%d' % self.dimensions)
        elif self.rescale:
            quality = .17 + (self.rescale / 100)
            height = 1000 * self.vbitrate / 25 / self.aspect / quality
            height = sqrt(height)
            height = int(height / 16) * 16
            if height < self.height:
                filter.append('scale=-2:%d' % height)
        return filter

    @cache
    def section(self):
        """Start/stop position to play at"""
        section = []
        if self.begin:
            section += ['-ss', self.begin]
        if self.time:
            section += ['-endpos', self.time]
        return section

    # wrappers to run process

    def mplayer(self, *args, **kwargs):
        """Run MPlayer on current video"""
        return self.runpty('mplayer', self.source, args, **kwargs)

    def mencoder(self, *args, **kwargs):
        """Run MEncoder on current video"""
        return self.runpty('mencoder', self.source, args, **kwargs)

    def wavegain(self, *args, **kwargs):
        """Run WaveGain"""
        return self.runpty('wavegain', args, **kwargs)

    # static utility methods

    @staticmethod
    def pad_num(val, size):
        """Add leading 0's to numbers"""
        val = str(val)
        return ('0' * (size - len(val))) + val

    @classmethod
    def clock(cls, seconds):
        """More precise readable clock"""
        minutes = seconds / 60
        seconds = cls.pad_num(int(seconds % 60), 2)
        hours = str(int(minutes / 60))
        minutes = cls.pad_num(int(minutes % 60), 2)
        clock = [hours, minutes]
        if hours == '0':
            clock.append(seconds)
        else:
            clock.append('00')
        return ':'.join(clock)

    @classmethod
    def readable(cls, n):
        """Convert seconds into human readable format"""
        units = []
        for unit, size in cls._time_units:
            n = int(n)
            if size and n >= size:
                remainder = n % size
                n = n / size
            else:
                remainder = n
                n = 0
            if remainder:
                if remainder > 1:
                    unit += 's'
                units.append('%s %s' % (remainder, unit))
            if not n:
                break
        if units:
            return units[-1]

    @classmethod
    def mkshell(cls, *args):
        """Make human-readable shell script"""
        args = cls.collapse(args)
        cmd = args.pop(0)
        lines = []
        for arg in args:
            if arg.startswith('-') or not lines:
                lines.append([])
            lines[-1].append(arg)
        shell = [cmd]
        for line in lines:
            shell.append('    ' + ' '.join(line))
        for i in range(len(shell) - 1):
            shell[i] += ' \\'
        return shell

    @staticmethod
    def newfile(base, ext):
        """Find unused file made with base/ext"""
        i = 0
        while True:
            file = base
            if i:
                file += '.%d' % i
            file += '.' + ext
            if not os.path.exists(file):
                return file
            i += 1

    @staticmethod
    def quote(arg):
        """Safely quote arg for shell output"""
        return "'" + arg.replace("'", "'\"'\"'") + "'"

    @staticmethod
    def round(val, boundary=None):
        """Round value to interval boundary"""
        val = int(round(val))
        if boundary:
            offset = val % boundary
            val -= offset
            if offset >= (boundary / 2):
                val += boundary
        return val

    @classmethod
    def runpty(cls, *args, **kwargs):
        readline = kwargs.get('readline', False)
        show = kwargs.get('show', False)
        newline = kwargs.get('newline', '\n')

        """Run command in a PTY and yield output"""

        # parse arguments
        args = cls.collapse(args)
        if show:
            cmd = []
            for arg in args:
                if isinstance(arg, unicode):
                    arg = tmp.encode('utf8')
                try:
                    if len(shlex.split(arg)) != 1:
                        raise ValueError('has a space in it')
                except ValueError:
                    arg = cls.quote(arg)
                cmd.append(arg)
            log.info(' '.join(cmd))
        else:
            log.debug(args)

        r = RunPTY(args, decode=False)
        while r.opened:
            line = r.readline(newline=newline)
            if not line:
                break
            yield line

    @classmethod
    def collapse(cls, args):
        """Collapse nested data structures to list of strings"""
        new_args = []
        for arg in args:
            if arg is None:
                continue
            if isinstance(arg, dict):
                arg = arg.items()
            if isinstance(arg, (list, set, tuple)):
                new_args.extend(cls.collapse(arg))
            else:
                new_args.append(arg)
        return map(str, new_args)

    @staticmethod
    def cast(val):
        """Change type to float/int if appropriate"""
        try:
            return int(val)
        except ValueError:
            try:
                return float(val)
            except ValueError:
                return val

    def __repr__(self):
        return '<%s object at 0x%x: %s>' % (
                self.__class__.__name__, id(self), self.__dict__)


def add_logfile(file):
    handler = log.FileHandler(file)
    handler.setFormatter(log.Formatter(LOG['format'], LOG['datefmt']))
    log.root.addHandler(handler)
    log.info('logging to file: ' + file)


def add_action(option, opt_str, val, optparse, action):
    if action not in optparse.actions:
        optparse.actions.append(action)


def set_vbitrate(option, opt_str, val, optparse):
    optparse.values._vbitrate = val
    optparse.values.size = None


def set_size(option, opt_str, val, optparse):
    optparse.values.size = val
    optparse.values._vbitrate = None


def set_dimensions(option, opt_str, val, optparse):
    optparse.values.dimensions = val
    optparse.values.rescale = None


def set_rescale(option, opt_str, val, optparse):
    if val < 1 or val > 10:
        optparse.error('invalid rescale range')
    optparse.values.rescale = val
    optparse.values.dimensions = None


def apply_preset(option, opt_str, val, optparse):
    if val == 'help':
        for key in PRESETS:
            print '    ' + key
        os._exit(1)
    if val not in PRESETS:
        optparse.error('invalid preset option: ' + val)
    optparse.values.__dict__.update(PRESETS[val])
    log.info('applied presets: ' + val)


def init():
    """Parse command-line options and initialize"""
    log.basicConfig(**LOG)
    optparse = OptionParser(version=__version__, usage=USAGE)
    optparse.actions = []

    group = optparse.add_option_group('Actions')
    group.add_option(
            '-i', '--identify', action='callback', callback=add_action,
            callback_args=('identify',), help='show video parameters')
    group.add_option(
            '-P', '--play', action='callback', callback=add_action,
            callback_args=('play',), help='play video')
    group.add_option(
            '-e', '--encode', action='callback', callback=add_action,
            callback_args=('encode',), help='encode video (default)')

    group = optparse.add_option_group('Preset Options')
    group.add_option(
            '-p', '--preset', metavar='<name>', action='callback',
            type='string', callback=apply_preset,
            help='apply preset options [%s]' % '|'.join(PRESETS))
    group.add_option(
            '-f', '--format', dest='_format',
            metavar='<%s>' % '|'.join(VidConvert._formats), default='divx',
            type='choice', choices=VidConvert._formats.keys(),
            help='encode to format (default: %default)')
    group.add_option(
            '-v', '--vbitrate', dest='_vbitrate', metavar='<kbps>',
            action='callback', type='float', callback=set_vbitrate,
            help='encode to video bitrate')
    group.add_option(
            '-s', '--size', metavar='<size>', action='callback', type='string',
            callback=set_size, help='encode to size: n[mg] or n/(cdr|dvd)')
    group.add_option(
            '-a', '--abitrate', dest='_abitrate', metavar='<kbps>',
            type='float', help='encode to audio bitrate')
    group.add_option(
            '--aexact', default=False, action='store_true',
            help='Use exact abitrate provided')
    group.add_option(
            '--vbr', dest='_usevbr', default=False, action='store_true',
            help='use VBR 5 (~130kbps) for audio (divx only)')
    group.add_option(
            '-D', '--dimensions', metavar='<width> <height>',
            action='callback', type='int', nargs=2, callback=set_dimensions,
            help='force to width/height, letterboxing if necessary')
    group.add_option(
            '-r', '--rescale', metavar='<1-10>', action='callback',
            type='float', callback=set_rescale,
            help='automatically scale down to optimal dimensions for quality')
    group.add_option(
            '-N', '--normalize', default=False, action='store_true',
            help='use wavegain to calculate dB gain (slow as hell)')
    group.add_option(
            '--nosound', default=False, action='store_true',
            help='disable sound encoding')

    group = optparse.add_option_group('Encoding Options')
    group.add_option(
            '-1', '--onepass', dest='passes', default=2, action='store_const',
            const=1, help='use 1-pass encoding')
    group.add_option(
            '-2', '--twopass', dest='passes', default=2, action='store_const',
            const=2, help='use 2-pass encoding')
    group.add_option(
            '-n', '--non-interlaced', dest='interlaced', default=True,
            action='store_false', help="don't use pullup")
    group.add_option(
            '-F', '--fast', dest='quality', default='high',
            action='store_const', const='low',
            help='use lower quality encoding options')
    group.add_option(
            '-B', '--begin', metavar='<start>', help='time code to start at')
    group.add_option(
            '-T', '--time', metavar='<length>', help='length of encode time')
    group.add_option(
            '-L', '--scantime', metavar='<samples> <size>',
            default=VidConvert._default_scantime, type='int', nargs=2,
            help='scan time for bitrates/crop (default: %default)')
    group.add_option(
            '--threads', metavar='<#>', type='int', help='lavc threads')

    group = optparse.add_option_group('Output Options')
    group.add_option(
            '-b', '--basename', dest='_basename', metavar='<name>',
            help="output files basename (default: auto)")
    group.add_option(
            '--namekey', metavar='<name>', help='append tag to output files')
    group.add_option(
            '-g', '--generate', default=False, action='store_true',
            help='generate shell code instead of running')
    group.add_option(
            '-o', '--output', metavar='<file>',
            help='force shell output to append file (- for stdout)')

    group = optparse.add_option_group('Input Options')
    group.add_option(
            '-c', '--channels', dest='_channels', metavar='<#>', type='int',
            help='override default channels')
    group.add_option(
            '-M', '--mono', default=False, action='store_true',
            help='downsample to mono')
    group.add_option(
            '--srate', default=48000, type='int',
            help='resample to rate (%default)')
    group.add_option(
            '-t', '--title', dest='_title', metavar='<id>', type='int',
            help='dvd title to play (default: auto)')
    group.add_option(
            '-A', '--aid', metavar='<id>', type='int',
            help='audio track (default: auto)')
    group.add_option(
            '-S', '--sid', metavar='<id>', type='int', help='subtitle track')
    group.add_option(
            '-H', '--hardsub', metavar='<file>', help='hard code subtitle file')

    group = optparse.add_option_group('Logging Options')
    group.add_option(
            '-d', '--debug', action='callback', callback=lambda *x:
            log.root.setLevel(log.DEBUG), help='show debug messages')
    group.add_option(
            '-q', '--quiet', action='callback', callback=lambda *x:
            log.root.setLevel(log.WARN), help='only show error messages')
    group.add_option(
            '-l', '--logfile', metavar='<file>', action='callback',
            type='string', callback=lambda *x: add_logfile(x[2]),
            help='log messages to <file>')


    opts, args = optparse.parse_args()
    del opts.logfile, opts.preset
    if not args:
        optparse.error('need to specify a file or dvd directory')
    if not optparse.actions:
        optparse.actions = ['encode']
    return opts.__dict__, args, optparse.actions


def main():
    opts, files, actions = init()
    for file in files:
        try:
            vc = VidConvert(file, **opts)
            log.info('processing video: ' + (file if file else 'dvd'))
            for action in actions:
                log.info('performing action: ' + action)
                getattr(vc, action)()
        except InvalidParameter, error:
            log.error('invalid parameter: %s' % error)
            break
        except InvalidMedia, error:
            log.error('invalid media: %s' % error)
        except MPlayerError, error:
            log.error(error)
        except VidConvertError, error:
            log.error(error)
        except KeyboardInterrupt:
            break
    return 0


if __name__ == '__main__':
    sys.exit(main())
