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