709 lines
		
	
	
	
		
			26 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			709 lines
		
	
	
	
		
			26 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
| #!/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)
 |