rip-py: Split things and support Blu-ray

We're using a `libbluray2` wrapper by the same person that also built
`pydvdread`. At the same time, we dump supporting to parse `mpv` output,
because that was too unreliable anyways and keeping it just means more
maintenance overhead.

To provide a better overview, we've split up the DVD and Blu-ray
functionality into own modules
This commit is contained in:
MasterofJOKers 2024-01-05 22:28:30 +01:00
parent cf53e5dd3d
commit d17af01e2f
6 changed files with 1061 additions and 814 deletions

View File

@ -1,813 +0,0 @@
#!/usr/bin/env python3
from datetime import datetime, timedelta
import itertools
import math
import re
import os
import shlex
import subprocess
import sys
import tempfile
import click
try:
import dvdread
HAS_DVDREAD = True
except ImportError:
HAS_DVDREAD = False
FILE_TYPE = click.Path(file_okay=True, dir_okay=False, exists=True)
NEW_FILE_TYPE = click.Path(file_okay=True, dir_okay=False, exists=False)
DIRECTORY_TYPE = click.Path(file_okay=False, dir_okay=True, exists=True)
DEVICE_OR_DIRECTORY_TYPE = click.Path(file_okay=True, dir_okay=True,
exists=True)
@click.group()
def cli():
pass
def with_fifo(func):
def f(*args, **kwargs):
with tempfile.TemporaryDirectory() as tmpdir:
fifo_path = os.path.join(tmpdir, 'rip.fifo')
try:
os.mkfifo(fifo_path)
except FileExistsError:
pass
kwargs['fifo_path'] = fifo_path
return func(*args, **kwargs)
return f
def _get_audio_map(title=None, mpv_output=None, device=None):
# FIXME this doesn't always work. ffmpeg seems to order audio stream the
# other way around or something
if (None, None) == (title, mpv_output):
raise RuntimeError('Either `title` or `mpv_output` is required.')
if mpv_output is None:
mpv_cmd = ['mpv', '--quiet', '--frames=0',
'dvd://{}'.format(title)]
if device:
mpv_cmd.append('--dvd-device={}'.format(device))
mpv_output = subprocess.check_output(mpv_cmd,
stderr=subprocess.DEVNULL)
mpv_output = mpv_output.decode('utf-8')
audio_map = []
for line in mpv_output.splitlines():
# [dvd] audio stream: 0 format: mpeg1 (stereo) language: de aid: 0.
# FIXME use the aid as ffmpeg should support -map i:0x80, too
# or another one
# [dvd] audio stream: 0 format: ac3 (5.1) language: de aid: 128.
# [dvd] audio stream: 1 format: ac3 (5.1) language: en aid: 129.
m = re.match(r'\[dvd\] audio stream: (\d+) .* language: (\S+) .*',
line)
if m:
audio_map.append((int(m.group(1)), m.group(2)))
if not audio_map:
print('#' * 15)
print('WARNING: Could not find any audio streams.')
print('#' * 15)
return audio_map
def _get_audios(title, device):
if HAS_DVDREAD:
with dvdread.DVD(device) as d:
d.Open()
if not 0 <= title <= d.NumberOfTitles:
msg = 'Number of titles of of range. Use a number in [0, {}['
raise click.ClickException(msg.format(d.NumberOfTitles))
# DvdRead starts counting titles at 1, we start at 0
title += 1
t = d.GetTitle(title)
audio_map = []
for i in range(1, t.NumberOfAudios + 1):
a = t.GetAudio(i)
audio_map.append((128 + i - 1, a.LangCode))
return audio_map
else:
# FIXME can this be _get_audio_map?
raise NotImplementedError(
'Need to do this via ffmpeg/ffprobe/mpv '
'somehow. Make sure you make sense of mpv showing the audio '
'streams in another order than they appear in the file in the '
'end ..')
def _get_subtitles(title, device):
if HAS_DVDREAD:
with dvdread.DVD(device) as d:
d.Open()
if not 0 <= title <= d.NumberOfTitles:
msg = 'Number of titles of of range. Use a number in [0, {}['
raise click.ClickException(msg.format(d.NumberOfTitles))
# DvdRead starts counting titles at 1, we start at 0
title += 1
t = d.GetTitle(title)
subtitles = []
for i in range(1, t.NumberOfSubpictures + 1):
s = t.GetSubpicture(i)
subtitles.append((0x20 + i - 1, s.LangCode))
return subtitles
else:
raise NotImplementedError()
def _get_parameterized_ffmpeg_cmd(media_type, quality, extra_opts, use_gpu,
audio_map, subtitle_map, pre_read=1024):
ffmpeg_cmd = []
ffmpeg_cmd.append(['ffmpeg',
'-probesize', '{}M'.format(pre_read),
'-analyzeduration', '{}M'.format(pre_read),
'-hwaccel', 'auto',
'-i'])
ffmpeg_cmd.append(extra_opts)
ffmpeg_cmd[-1].extend(['-map', '0:v:0'])
if '-c:a' not in ffmpeg_cmd[-1]:
ffmpeg_cmd[-1].extend(['-c:a', 'copy'])
ffmpeg_cmd[-1].extend(['-c:s', 'copy'])
if use_gpu:
ffmpeg_cmd[-1] += [
'-c:v', 'h264_nvenc',
'-rc', 'vbr_hq',
'-rc-lookahead', '20',
'-cq', '{}'.format(quality),
# '-maxrate:v', '3M',
# '-b:v', '8M', '-maxrate:v', '10M',
'-preset:v', 'slow']
else:
ffmpeg_cmd[-1] += [
'-c:v', 'h264',
'-crf', '{}'.format(quality),
'-preset:v', 'slower',
'-x264-params', 'opencl=true',
# '-x264-params', 'keyint=240:min-keyint=20',
]
ffmpeg_cmd[-1] += [
'-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
# '-profile:v', 'baseline',
# '-level', '3.0',
]
if audio_map:
for i, (channel, lang) in enumerate(audio_map):
if channel < 0x80:
map_target = '0:a:{}'.format(channel)
else:
map_target = 'i:0x{:x}'.format(channel)
ffmpeg_cmd[-1].extend([
'-map', map_target,
'-metadata:s:a:{}'.format(i), 'language={}'.format(lang)
])
else:
ffmpeg_cmd[-1].extend(['-map', '0:a:0', '-metadata:s:a:0', 'language=de'])
if subtitle_map:
for i, (channel, lang) in enumerate(subtitle_map):
if channel < 0x20:
map_target = '0:s:{}'.format(channel)
else:
map_target = 'i:0x{:x}'.format(channel)
ffmpeg_cmd[-1].extend([
'-map', map_target,
'-metadata:s:s:{}'.format(i), 'language={}'.format(lang)
])
# TODO we are probably able to do forced subtitles. we probably want that
# we can set a default stream. make this available somehow
# -disposition:a:1 default
if media_type and not use_gpu:
ffmpeg_cmd[-1].extend(['-tune:v', media_type])
def parameterized(output_file, input_file):
return ffmpeg_cmd[0] + [input_file] + ffmpeg_cmd[1] + [output_file]
return parameterized
def _get_ffmpeg_cmd(output_file, input_path, media_type, quality, extra_opts,
use_gpu, audio_map, subtitle_map):
cmd = _get_parameterized_ffmpeg_cmd(media_type, quality, extra_opts,
use_gpu, audio_map, subtitle_map)
cmd = cmd(output_file=output_file, input_file=input_path)
print(cmd)
return cmd
def _get_mpv_dump_cmd(title, output_path, device=None):
cmd = ['mpv', '--quiet', '--stream-dump={}'.format(output_path),
'dvd://{}'.format(title)]
if device:
cmd.append('--dvd-device={}'.format(device))
return cmd
@with_fifo
def _rip_title(title, output_dir, ffmpeg_cmd, fifo_path, device=None,
output_file_suffix='.mkv'):
filename = '{}{}'.format(title, output_file_suffix)
out_file = os.path.join(output_dir, filename)
mpv_cmd = _get_mpv_dump_cmd(title, fifo_path, device)
mpv_proc = subprocess.Popen(mpv_cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
ffmpeg_cmd = ffmpeg_cmd(output_file=out_file, input_file=fifo_path)
print(ffmpeg_cmd)
subprocess.check_call(ffmpeg_cmd)
mpv_output = mpv_proc.stdout.read().decode('utf-8')
return out_file, mpv_output
def _get_title_chapters(title=None, mpv_output=None, device=None):
if HAS_DVDREAD:
return _get_title_chapters_dvdread(title=title, device=device)
else:
return _get_title_chapters_mpv(title=title, mpv_output=mpv_output,
device=device)
def _get_title_chapters_dvdread(title, device='/dev/dvd'):
# we usually start at 0, but dvdread starts at 1
title += 1
with dvdread.DVD(device) as d:
d.Open()
if title > d.NumberOfTitles:
msg = 'Given title {} doesn\'t exist on DVD (has {} titles).'
raise RuntimeError(msg.format(title, d.NumberOfTitles))
t = d.GetTitle(title)
# we need to put in at least a microsecond to get the full
# 0:00:00.000001 format
td = timedelta(microseconds=1)
chapters = [str(td)]
for i in range(1, t.NumberOfChapters + 1):
td += timedelta(milliseconds=t.GetChapter(i).Length)
chapters.append(str(td))
return chapters
def _get_title_chapters_mpv(title=None, mpv_output=None, device=None):
if (None, None) == (title, mpv_output):
raise RuntimeError('Either `title` or `mpv_output` is required.')
print('Getting chapters from mpv might miss some at the end. '
'Please install the dvdread python packages.', file=sys.stderr)
if mpv_output is None:
mpv_cmd = ['mpv', '--quiet', '--frames=0',
'dvd://{}'.format(title)]
if device:
mpv_cmd.append('--dvd-device={}'.format(device))
mpv_output = subprocess.check_output(mpv_cmd,
stderr=subprocess.DEVNULL)
mpv_output = mpv_output.decode('utf-8')
for line in mpv_output.splitlines():
m = re.match(r'\[dvd\] CHAPTERS: (\S*)', line)
if m:
chapters = m.group(1)
break
else:
raise RuntimeError('Could not find CHAPTERS in mpv output.')
chapters = chapters.split(',')
chapters = [c for c in chapters if c] # filter empty/last
return chapters
def _convert_chapters_to_mkvmerge_format(chapters):
lines = []
number_of_zeros = 0
if len(chapters) > 1:
number_of_zeros = int(math.log10(len(chapters) - 1))
chapter_template = 'CHAPTER{{:0{}d}}'.format(number_of_zeros)
for i, timestamp in enumerate(chapters):
chapter_str = chapter_template.format(i)
# if we start with e.g. 0:21, we need another 0 to be compatible
if len(timestamp.split(':')[0]) == 1:
timestamp = '0' + timestamp
# if we have a higher resolution than 1 millisecond, we need to cut
# some chars
if len(timestamp.split('.')[-1]) > 3:
strip_length = len(timestamp.split('.')[-1]) - 3
timestamp = timestamp[:-strip_length]
lines.append('{}={}'.format(chapter_str, timestamp))
lines.append('{}NAME='.format(chapter_str))
return os.linesep.join(lines)
def _set_chapters(mkv_file, chapters):
if not os.path.exists(mkv_file):
raise RuntimeError('MKV file does not exist.')
with tempfile.NamedTemporaryFile() as f:
mkvmerge_chapters = _convert_chapters_to_mkvmerge_format(chapters)
f.write(mkvmerge_chapters.encode('utf-8'))
f.flush()
mkvpropedit_cmd = ['mkvpropedit', mkv_file, '--chapters', f.name]
subprocess.check_call(mkvpropedit_cmd)
def _get_dvd_titles(device=None):
if HAS_DVDREAD:
return _get_dvd_titles_dvdread(device=device)
else:
return _get_dvd_titles_mpv(device=device)
def _get_dvd_titles_dvdread(device='/dev/dvd'):
with dvdread.DVD(device) as d:
d.Open()
return d.NumberOfTitles
def _get_dvd_titles_mpv(device=None):
mpv_cmd = ['mpv', '--vo', 'null', '--quiet', '--frames=0', 'dvd://']
if device:
mpv_cmd.append('--dvd-device={}'.format(device))
out = subprocess.check_output(mpv_cmd, stderr=subprocess.STDOUT)
out = out.decode('utf-8')
for line in out.splitlines():
m = re.match(r'^\[dvd\] There are (\d+) titles on this DVD.', line)
# next line doesn't work on DVDs with one titleset having multiple
# titles
# m = re.match(r'^libdvdread: Found (\d+) VTS', line)
if m:
titles = int(m.group(1))
break
else:
raise RuntimeError('Could not find the number of VTS in mpv output.')
return titles
@cli.command()
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
@click.option('--media-type', type=click.Choice(['film', 'animation']))
@click.option('--keep-audio', is_flag=True)
@click.option('--audio-map', multiple=True, type=(int, str),
help="Keep specific audio. Order is important. Takes first "
"audio stream and puts it at stream INT in the new file "
"with language STR.")
@click.option('--subtitle-map', multiple=True, type=(int, str),
help="Keep the specific subtitles. Order is important. Takes "
"stream defined as INT into place according to order with "
"language STR.")
@click.option('--quality', type=click.INT, default=22, show_default=True)
@click.option('--start-at-title', type=click.INT, default=0, show_default=True)
@click.option('--end-at-title', type=click.INT, default=-1)
@click.option('-t', '--title', 'titles', multiple=True, type=click.INT)
@click.option('--deinterlace', is_flag=True)
@click.option('--pre-read-size', type=click.INT, default=1024, show_default=True)
@click.option('--ffmpeg-opts')
def rip(output_dir, titles, device=None, media_type=None, keep_audio=False,
quality=18, start_at_title=0, end_at_title=-1, ffmpeg_opts=None,
deinterlace=False, audio_map=None, subtitle_map=None, pre_read_size=1024):
if end_at_title >= 0 and start_at_title > end_at_title:
raise click.ClickException('--start-at-title cannot be larger than --end-at-title.')
if ffmpeg_opts is None:
ffmpeg_opts = []
else:
ffmpeg_opts = shlex.split(ffmpeg_opts)
if deinterlace:
ffmpeg_opts.extend(['-vf', 'yadif'])
if titles:
titles = sorted(t for t in titles if t >= start_at_title)
else:
titles = _get_dvd_titles(device=device)
if end_at_title >= 0:
if end_at_title > titles:
raise click.ClickException('--end-at-title {} larger than number of titles {}'
.format(end_at_title, titles))
# we'll put it into a Python range(), so we need to add 1 to make
# it include that title
end_at_title += 1
else:
end_at_title = titles
titles = range(start_at_title, end_at_title)
for title in titles:
if keep_audio:
audio_map = _get_audio_map(title, device=device)
use_gpu = False
ffmpeg_cmd = _get_parameterized_ffmpeg_cmd(media_type, quality,
ffmpeg_opts, use_gpu,
audio_map, subtitle_map,
pre_read=pre_read_size)
out_file, mpv_output = _rip_title(title, output_dir, ffmpeg_cmd, device=device)
chapters = _get_title_chapters(title=title, mpv_output=mpv_output,
device=device)
if chapters:
_set_chapters(out_file, chapters)
@cli.command('convert')
@click.option('-i', '--input-file', type=FILE_TYPE, required=True)
@click.option('-o', '--output-file', type=NEW_FILE_TYPE, required=True)
@click.option('--media-type', type=click.Choice(['film', 'animation']))
@click.option('--quality', type=click.INT, default=18, show_default=True)
@click.option('--deinterlace', is_flag=True)
@click.option('--ffmpeg-opts')
@click.option('--audio-map', multiple=True, type=(int, str),
help="Keep specific audio. Order is important. Takes first "
"audio stream and puts it at stream INT in the new file "
"with language STR.")
@click.option('--subtitle-map', multiple=True, type=(int, str),
help="Keep the specific subtitles. Order is important. Takes "
"stream defined as INT into place according to order with "
"language STR.")
@click.option('-c', '--chapters')
def convert(input_file, output_file, media_type, quality, deinterlace,
ffmpeg_opts, audio_map, subtitle_map, chapters):
if ffmpeg_opts is None:
ffmpeg_opts = []
else:
ffmpeg_opts = shlex.split(ffmpeg_opts)
if deinterlace:
ffmpeg_opts.extend(['-vf', 'yadif'])
if chapters:
chapters = chapters.split(',')
ffmpeg_cmd = _get_ffmpeg_cmd(output_file, input_file, media_type,
quality, ffmpeg_opts, False, audio_map,
subtitle_map)
subprocess.check_call(ffmpeg_cmd)
if chapters:
_set_chapters(output_file, chapters)
@cli.command('get-chapters')
@click.option('-t', '--title', type=int, required=True)
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
def get_chapters(title, device=None):
print(','.join(_get_title_chapters(title=title, device=device)))
@cli.command()
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-t', '--title', type=int, required=True)
@click.option('--pre-read-size', type=click.INT, default=1024, show_default=True)
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
def get_subtitles_file(output_dir, title, device=None, pre_read_size=1024):
# ffmpeg_cmd = ffmpeg_cmd(output_file=out_file, input_file=fifo_path)
ffmpeg_cmd = []
ffmpeg_cmd.append(['ffmpeg',
'-probesize', '{}M'.format(pre_read_size),
'-analyzeduration', '{}M'.format(pre_read_size),
'-hwaccel', 'auto',
'-i'])
ffmpeg_cmd.append([
'-map', '0:v:0',
'-c:v', 'h264',
'-crf', '75',
'-preset:v', 'veryfast',
'-x264-params', 'opencl=true',
# '-x264-params', 'keyint=240:min-keyint=20',
'-c:s', 'copy',
])
for i, (channel, lang) in enumerate(_get_subtitles(title, device)):
lang = '{} - 0x{:x}'.format(lang, channel)
ffmpeg_cmd[-1] += [
'-map', 'i:0x{:x}'.format(channel),
'-metadata:s:s:{}'.format(i), 'language={}'.format(lang)
]
def parameterized(output_file, input_file):
return ffmpeg_cmd[0] + [input_file] + ffmpeg_cmd[1] + [output_file]
out_file, mpv_output = _rip_title(title, output_dir, parameterized, device=device)
@cli.command()
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-t', '--title', type=int, required=True)
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
def ffprobe(output_dir, title, device):
ffmpeg_cmd = [['ffprobe', '-i']]
def parameterized(output_file, input_file):
return ffmpeg_cmd[0] + [input_file]
out_file, mpv_output = _rip_title(title, output_dir, parameterized, device=device)
@cli.command('set-chapters')
@click.option('-o', '--output-file', type=FILE_TYPE, required=True)
@click.option('-t', '--title', type=int)
@click.option('-c', '--chapters')
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
def set_chapters(output_file, title=None, chapters=None, device=None):
if (None, None) == (title, chapters):
raise RuntimeError('One of `title` and `chapters` is required.')
if chapters is None:
chapters = _get_title_chapters(title=title, device=device)
else:
chapters = chapters.split(',')
_set_chapters(output_file, chapters)
@cli.command('find-right-title')
@click.option('--start-title', type=int, default=0, show_default=True)
@click.option('--end-title', type=int)
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
def find_right_title(start_title, end_title, device):
if end_title and start_title > end_title:
msg = '--start-title must not be bigger than --end-title'
raise click.ClickException(msg)
titles = _get_dvd_titles(device=device)
if start_title > titles:
raise RuntimeError('DVD has less titles than start title.')
if end_title:
end_title = min(titles - 1, end_title)
else:
end_title = titles - 1
title_chapter_lengths = {}
for title in range(start_title, end_title + 1):
chapters = _get_title_chapters(title, device=device)
timestamps = []
for timestamp in chapters:
timestamp, microseconds = timestamp.split('.')
dt = datetime.strptime(timestamp, '%H:%M:%S')
dt = dt.replace(microsecond=int(microseconds) * 1000)
timestamps.append(dt)
lengths = []
if len(timestamps) > 1:
for i, timestamp in enumerate(timestamps[1:]):
lengths.append(timestamp - timestamps[i])
if not lengths:
continue
title_chapter_lengths[title] = lengths
# remove titles with duplicate chapters by looking at the length
title_chapter_lengths = {
title: lengths for title, lengths in title_chapter_lengths.items()
if len(lengths) == len(set(lengths))}
if title_chapter_lengths:
print('The titles {} contain no duplicate chapter lengths.'.format(
', '.join(str(i) for i in title_chapter_lengths.keys())))
else:
print('No title without duplicates found.')
@cli.command('split-file')
@click.option('-c', '--chapters-file', type=FILE_TYPE, required=True,
help='File with timestamps separated by comma')
@click.option('-i', '--input-file', type=FILE_TYPE, required=True)
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-n', '--number', type=click.INT,
help='Split out only one chapter given by this number.')
@click.option('--reencode', is_flag=True,
help='Don\'t use -c:v copy and -c:a copy')
@click.option('--media-type', type=click.Choice(['film', 'animation']),
help='Only relevant with --reencode')
@click.option('--quality', type=click.INT, default=18, show_default=True,
help='Only relevant with --reencode')
@click.option('--ffmpeg-opts',
help='Only relevant with --reencode')
@click.option('--audio-map', multiple=True, type=(int, str),
help="Keep specific audio. Only relevant with --reencode."
"Order is important. Takes first "
"audio stream and puts it at stream INT in the new file "
"with language STR.")
@click.option('--subtitle-map', multiple=True, type=(int, str),
help="Keep the specific subtitles. Order is important. Takes "
"stream defined as INT into place according to order with "
"language STR.")
def split_file(output_dir, input_file, chapters_file, number, reencode,
media_type, quality, ffmpeg_opts, audio_map, subtitle_map):
if ffmpeg_opts is None:
ffmpeg_opts = []
else:
ffmpeg_opts = shlex.split(ffmpeg_opts)
extension = input_file.split('.')[-1]
with open(chapters_file) as f:
chapters = f.read().strip().split(',')
for i, (start, end) in enumerate(itertools.zip_longest(chapters, chapters[1:]), start=1):
if number is not None and i != number:
continue
extra_opts = ffmpeg_opts + ['-ss', start]
if end is not None:
extra_opts.extend(['-to', end])
out_path = os.path.join(output_dir, '{}.{}'.format(i, extension))
if not reencode:
ffmpeg_cmd = ['ffmpeg'] + extra_opts + [
'-i', input_file,
'-c:a', 'copy',
'-c:v', 'copy',
'-movflags', '+faststart',
out_path
]
else:
use_gpu = False
ffmpeg_cmd = _get_ffmpeg_cmd(out_path, input_file, media_type,
quality, extra_opts, use_gpu,
audio_map, subtitle_map)
subprocess.check_call(ffmpeg_cmd)
@cli.command('titles')
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
def titles(device):
if HAS_DVDREAD:
with dvdread.DVD(device) as d:
d.Open()
for i in range(1, d.NumberOfTitles + 1):
t = d.GetTitle(i)
click.echo('{}: {}'.format(i - 1, t.PlaybackTimeFancy))
else:
click.echo(_get_dvd_titles(device=device))
@cli.command()
@click.option('-t', '--title', type=int, required=True)
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
def audios(device, title):
"""Retrieve the audio streams and their languages for a title"""
for i, (channel, lang) in enumerate(_get_audios(title, device)):
click.echo('{}: {} aid probably {} (0x{:x})'
.format(i, lang, channel, channel))
@cli.command()
@click.option('-t', '--title', type=int, required=True)
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
def subtitles(device, title):
"""Retrieve the subtitles streams and their languages for a title"""
for i, (channel, lang) in enumerate(_get_subtitles(title, device)):
print('{}: {} with id {} (0x{:x})'
.format(i, lang, channel, channel))
@cli.command()
@click.option('-t', '--title', type=int)
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
@click.option('--no-dump', is_flag=True)
@click.option('--no-audios', is_flag=True)
@click.option('--no-subtitles', is_flag=True)
@click.option('--no-chapters', is_flag=True)
def dump(title, output_dir, device, no_dump, no_audios, no_subtitles,
no_chapters):
"""Just dump the stream and additional infos"""
if title is None:
titles = range(0, _get_dvd_titles(device))
else:
titles = [title]
for title in titles:
if not no_dump:
dump_file = os.path.join(output_dir, '{}.dump'.format(title))
mpv_cmd = _get_mpv_dump_cmd(title, dump_file, device)
subprocess.check_call(mpv_cmd)
if not no_audios:
audios_file = os.path.join(output_dir, '{}.audios'.format(title))
with open(audios_file, 'w') as f:
audios = ['{}: {}'.format(c, l)
for c, l in _get_audios(title, device)]
f.write('\n'.join(audios))
if not no_subtitles:
subtitles_file = os.path.join(output_dir, '{}.subs'.format(title))
with open(subtitles_file, 'w') as f:
subtitles = ['{}: {}'.format(c, l)
for c, l in _get_subtitles(title, device)]
f.write('\n'.join(subtitles))
if not no_chapters:
chapters_file = os.path.join(output_dir, '{}.chapters'.format(title))
with open(chapters_file, 'w') as f:
chapters = _get_title_chapters(title, device=device)
f.write(','.join(chapters))
@cli.command()
def has_dvdread():
print(f"dvdread support: {'available' if HAS_DVDREAD else 'missing'}")
@cli.group()
def audio():
...
@audio.command()
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
@click.option('-t', '--title', type=int, required=True)
def rip_dvd_title(device, output_dir, title):
"""Rip the given title's audio from DVD"""
def partial(output_file, input_file):
return ['ffmpeg',
'-i', input_file,
'-map', '0:a:0',
'-c:a', 'flac',
output_file]
_rip_title(title, output_dir, partial, device=device,
output_file_suffix='.flac')
@audio.command('split-file')
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-c', '--chapters-file', type=FILE_TYPE, required=True,
help='File with timestamps separated by comma')
@click.option('-i', '--input-file', type=FILE_TYPE, required=True)
def audio_split_file(output_dir, chapters_file, input_file):
"""Split an audio file into different flac files based on timestamps in the chapters file"""
with open(chapters_file) as f:
chapters = f.read().strip().split(',')
times_ten = 0
while len(chapters) // (10 ** times_ten) > 9:
times_ten += 1
name_template = '{:0' + str(times_ten + 1) + 'd}.flac'
for i, (start, end) in enumerate(itertools.zip_longest(chapters, chapters[1:]), start=1):
ffmpeg_cmd = [
'ffmpeg',
'-ss', start]
if end is not None:
ffmpeg_cmd.extend(['-to', end])
out_path = os.path.join(output_dir, name_template.format(i))
ffmpeg_cmd.extend([
'-i', input_file,
'-c:a', 'flac',
'-movflags', '+faststart',
out_path])
subprocess.check_call(ffmpeg_cmd)
if __name__ == '__main__':
cli(complete_var='_RIP_PY_COMPLETE')

115
rip-py/rip_py/_bluray.py Normal file
View File

@ -0,0 +1,115 @@
import bluread
def get_title_audio_map(device, title):
with bluread.Bluray(device) as b:
b.Open()
if not 0 <= title < b.NumberOfTitles:
msg = f"Number of titles of of range. Use a number in [0, {b.NumberOfTitles}["
raise ValueError(msg)
t = b.GetTitle(title)
audio_map = set()
for i in range(0, t.NumberOfClips):
clip = t.GetClip(i)
for j in range(0, clip.NumberOfAudiosPrimary):
a = clip.GetAudio(j)
audio_map.add((0x1100 + j, a.Language))
return sorted(audio_map)
def get_title_subtitle_map(device, title):
with bluread.Bluray(device) as b:
b.Open()
if not 0 <= title < b.NumberOfTitles:
msg = f"Number of titles of of range. Use a number in [0, {b.NumberOfTitles}["
raise ValueError(msg)
t = b.GetTitle(title)
subtitles = set()
for i in range(0, t.NumberOfClips):
clip = t.GetClip(i)
for j in range(0, clip.NumberOfSubtitles):
s = clip.GetSubtitle(j)
subtitles.add((0x1200 + j, s.Language))
return sorted(subtitles)
def get_title_chapters(device, title):
with bluread.Bluray(device) as b:
b.Open()
if not 0 <= title < b.NumberOfTitles:
msg = f"Number of titles of of range. Use a number in [0, {b.NumberOfTitles}["
raise ValueError(msg)
t = b.GetTitle(title)
chapters = ['00:00:00.000,00']
for i in range(1, t.NumberOfChapters + 1):
c = t.GetChapter(i)
chapters.append(c.StartFancy)
return chapters
def get_num_titles(device):
with bluread.Bluray(device) as b:
b.Open()
return b.NumberOfTitles
def get_titles(device):
titles = []
with bluread.Bluray(device) as b:
b.Open()
for i in range(0, b.NumberOfTitles):
t = b.GetTitle(i)
titles.append(f"{t.LengthFancy} - {t.PlaylistNumber}")
return titles
def get_title_playlist_number(device, title):
with bluread.Bluray(device) as b:
b.Open()
return b.GetTitle(b.MainTitleNumber).PlaylistNumber
def get_main_title(device):
"""Return the main title number and it's playlist number as defined on the disk"""
with bluread.Bluray(device) as b:
b.Open()
return (b.MainTitleNumber, b.GetTitle(b.MainTitleNumber).PlaylistNumber)
def get_parameterized_mpv_dump_cmd(device):
"""Return function which accepts title and output_path as arguments and returns the final mpv dump command"""
cmd = ['mpv', '--quiet',
'--stream-dump={}',
'bd://mpls/{}',
f"--bluray-device={device}"]
def parameterized(title, output_path):
final_cmd = cmd.copy()
final_cmd[2] = final_cmd[2].format(output_path)
title = get_title_playlist_number(device, title)
final_cmd[3] = final_cmd[3].format(title)
return final_cmd
return parameterized
def get_mpv_dump_cmd(title, output_path, device):
"""Return a list to call mpv for dumping a BluRay title"""
return get_parameterized_mpv_dump_cmd(device)(title, output_path)

128
rip-py/rip_py/_dvd.py Normal file
View File

@ -0,0 +1,128 @@
from datetime import timedelta
import dvdread
# ## What I need from dvdread
# - audio information for each title
# - count
# - language code
# - subpicture (subtitle) information for each title
# - count
# - language code
# - chapter information per title
# - count
# - length
# - title information
# - number of titles
# - length ("playback time fancy")
# python3-mediainfodll
# In [71]: for i in range(308):
# ...: if not mi.GetI(MediaInfoDLL3.Stream.Video, 0, i, 1):
# ...: continue
# ...: print('{}: {} - {}'.format(mi.GetI(MediaInfoDLL3.Stream.Video, 0, i, 4)
# ...: , mi.GetI(MediaInfoDLL3.Stream.Video, 0, i, 1), mi.GetI(MediaInfoDLL3.Strea
# ...: m.Video, 0, i, 6)))
def get_title_audio_map(device, title):
with dvdread.DVD(device) as d:
d.Open()
if not 0 <= title <= d.NumberOfTitles:
msg = f"Number of titles of of range. Use a number in [0, {d.NumberOfTitles}["
raise ValueError(msg)
# DvdRead starts counting titles at 1, we start at 0
title += 1
t = d.GetTitle(title)
audio_map = []
for i in range(1, t.NumberOfAudios + 1):
a = t.GetAudio(i)
audio_map.append((128 + i - 1, a.LangCode))
return audio_map
def get_title_subtitle_map(device, title):
with dvdread.DVD(device) as d:
d.Open()
if not 0 <= title <= d.NumberOfTitles:
msg = f"Number of titles of of range. Use a number in [0, {d.NumberOfTitles}["
raise ValueError(msg)
# DvdRead starts counting titles at 1, we start at 0
title += 1
t = d.GetTitle(title)
subtitles = []
for i in range(1, t.NumberOfSubpictures + 1):
s = t.GetSubpicture(i)
subtitles.append((0x20 + i - 1, s.LangCode))
return subtitles
def get_title_chapters(device, title):
with dvdread.DVD(device) as d:
d.Open()
if not 0 <= title <= d.NumberOfTitles:
msg = f"Number of titles of of range. Use a number in [0, {d.NumberOfTitles}["
raise ValueError(msg)
# we usually start at 0, but dvdread starts at 1
title += 1
t = d.GetTitle(title)
# we need to put in at least a microsecond to get the full
# 0:00:00.000001 format
td = timedelta(microseconds=1)
chapters = [str(td)]
for i in range(1, t.NumberOfChapters + 1):
td += timedelta(milliseconds=t.GetChapter(i).Length)
chapters.append(str(td))
return chapters
def get_num_titles(device):
with dvdread.DVD(device) as d:
d.Open()
return d.NumberOfTitles
def get_titles(device):
titles = []
with dvdread.DVD(device) as d:
d.Open()
for i in range(1, d.NumberOfTitles + 1):
t = d.GetTitle(i)
titles.append(t.PlaybackTimeFancy)
return titles
def get_parameterized_mpv_dump_cmd(device=None):
"""Return function which accepts title and output_path as arguments and returns the final mpv dump command"""
cmd = ['mpv', '--quiet',
'--stream-dump={}',
'dvd://{}']
if device:
cmd.append(f"--dvd-device={device}")
def parameterized(title, output_path):
final_cmd = cmd.copy()
final_cmd[2] = final_cmd[2].format(output_path)
final_cmd[3] = final_cmd[3].format(title)
return final_cmd
return parameterized
def get_mpv_dump_cmd(title, output_path, device=None):
"""Return a list to call mpv for dumping a BluRay title"""
return get_parameterized_mpv_dump_cmd(device=device)(title, output_path)

618
rip-py/rip_py/cli.py Normal file
View File

@ -0,0 +1,618 @@
from datetime import datetime
import functools
import itertools
from pathlib import Path
import shlex
import subprocess
import click
from rip_py import _bluray, _dvd, utils
FILE_TYPE = click.Path(file_okay=True, dir_okay=False, exists=True, path_type=Path)
NEW_FILE_TYPE = click.Path(file_okay=True, dir_okay=False, exists=False, path_type=Path)
DIRECTORY_TYPE = click.Path(file_okay=False, dir_okay=True, exists=True, path_type=Path)
DEVICE_OR_DIRECTORY_TYPE = click.Path(file_okay=True, dir_okay=True, exists=True, path_type=Path)
def scale_convert(ctx, param, value):
if value == '720p':
return '-1:720'
if value == '480p':
return '-1:480'
return value
def _rip(module, output_dir, titles, device, media_type, keep_audio, quality,
start_at_title, end_at_title, ffmpeg_opts, deinterlace, audio_map,
subtitle_map, pre_read_size, scale=None):
if end_at_title >= 0 and start_at_title > end_at_title:
raise click.ClickException('--start-at-title cannot be larger than --end-at-title.')
ffmpeg_opts = shlex.split(ffmpeg_opts) if ffmpeg_opts is not None else []
video_filters = []
if deinterlace:
video_filters.append('yadif')
if titles:
titles = sorted(t for t in titles if t >= start_at_title)
else:
titles = module.get_num_titles(device)
if end_at_title >= 0:
if end_at_title > titles:
raise click.ClickException(f"--end-at-title {end_at_title} larger than number of titles {titles}")
# we'll put it into a Python range(), so we need to add 1 to make
# it include that title
end_at_title += 1
else:
end_at_title = titles
titles = range(start_at_title, end_at_title)
for title in titles:
if keep_audio:
audio_map = module.get_title_audio_map(device, title)
use_gpu = False
ffmpeg_cmd = utils.get_parameterized_ffmpeg_cmd(
media_type, quality, ffmpeg_opts, use_gpu, audio_map, subtitle_map,
pre_read=pre_read_size, video_filters=video_filters, scale=scale)
mpv_cmd = module.get_parameterized_mpv_dump_cmd(device)
out_file = utils.rip_title(title, output_dir, mpv_cmd, ffmpeg_cmd)
chapters = module.get_title_chapters(device, title)
if chapters:
utils.set_chapters(out_file, chapters)
@click.group()
def cli():
pass
@cli.group()
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
@click.pass_context
def dvd(ctx, device):
ctx.ensure_object(dict)
ctx.obj['device'] = str(device)
def pass_device(fn):
@functools.wraps(fn)
@click.pass_obj
def wrapped(obj, *args, **kwargs):
kwargs['device'] = obj['device']
return fn(*args, **kwargs)
return wrapped
@dvd.group('info')
def dvd_info():
...
@dvd_info.command('audios')
@click.option('-t', '--title', type=int, required=True)
@pass_device
def dvd_info_audios(device, title):
"""Show the audio streams and their languages for a title"""
for i, (channel, lang) in enumerate(_dvd.get_title_audio_map(device, title)):
print(f"{i}: {lang} aid probably {channel} (0x{channel:x})")
@dvd_info.command('chapters')
@click.option('-t', '--title', type=int, required=True)
@pass_device
def dvd_info_chapters(title, device):
"""Show the chapters for a title"""
print(','.join(_dvd.get_title_chapters(device, title)))
@dvd_info.command('ffprobe')
@click.option('-t', '--title', type=int, required=True)
@pass_device
def dvd_info_ffprobe(title, device):
"""Run ffprobe on the given title
Output is saved into a file named like the title number in the given directory.
"""
ffmpeg_cmd = [['ffprobe', '-i']]
def parameterized(output_file, input_file):
return ffmpeg_cmd[0] + [input_file]
mpv_cmd = _dvd.get_parameterized_mpv_dump_cmd(device)
utils.rip_title(title, Path('/tmp'), mpv_cmd)
@dvd_info.command('subtitles')
@click.option('-t', '--title', type=int, required=True)
@pass_device
def dvd_info_subtitles(device, title):
"""Show the subtitle streams and their languages for a title"""
for i, (channel, lang) in enumerate(_dvd.get_title_subtitle_map(device, title)):
print(f"{i}: {lang} with id {channel} (0x{channel:x})")
@dvd_info.command('titles')
@pass_device
def dvd_info_titles(device):
"""Show the titles and their length"""
for i, t in enumerate(_dvd.get_titles(device)):
print(f"{i}: {t}")
@dvd.command()
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-t', '--title', type=int, required=True)
@click.option('--pre-read-size', type=int, default=1024, show_default=True)
@pass_device
def get_subtitles_file(output_dir, title, device, pre_read_size):
"""Write out the subtitles of the given title into the given directory"""
# ffmpeg_cmd = ffmpeg_cmd(output_file=out_file, input_file=fifo_path)
# FIXME can we move this out of the `cli` module?
ffmpeg_cmd = []
ffmpeg_cmd.append(['ffmpeg',
'-probesize', '{}M'.format(pre_read_size),
'-analyzeduration', '{}M'.format(pre_read_size),
'-hwaccel', 'auto',
'-i'])
ffmpeg_cmd.append([
'-map', '0:v:0',
'-c:v', 'h264',
'-crf', '75',
'-preset:v', 'veryfast',
'-x264-params', 'opencl=true',
# '-x264-params', 'keyint=240:min-keyint=20',
'-c:s', 'copy',
])
for i, (channel, lang) in enumerate(_dvd.get_title_subtitle_map(device, title)):
lang = f"{lang} - 0x{channel:x}"
ffmpeg_cmd[-1] += [
'-map', f"i:0x{channel:x}",
f"-metadata:s:s:{i}", f"language={lang}"
]
def parameterized(output_file, input_file):
return ffmpeg_cmd[0] + [input_file] + ffmpeg_cmd[1] + [output_file]
mpv_cmd = _dvd.get_parameterized_mpv_dump_cmd(device)
utils.rip_title(title, output_dir, mpv_cmd, parameterized)
@dvd.command()
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-t', '--title', type=int)
@click.option('--no-dump', is_flag=True)
@click.option('--no-audios', is_flag=True)
@click.option('--no-subtitles', is_flag=True)
@click.option('--no-chapters', is_flag=True)
def dump(title, output_dir, device, no_dump, no_audios, no_subtitles,
no_chapters):
"""Just dump the stream and additional infos
It's basically ripping without converting."""
if title is None:
titles = range(0, _dvd.get_num_titles(device))
else:
titles = [title]
for title in titles:
if not no_dump:
dump_file = str(output_dir / f"{title}.dump")
mpv_cmd = _dvd.get_mpv_dump_cmd(title, dump_file, device)
subprocess.check_call(mpv_cmd)
if not no_audios:
audios_file = str(output_dir / f"{title}.audios")
with open(audios_file, 'w') as f:
audios = [f"{c}: {l}"
for c, l in _dvd.get_title_audio_map(device, title)]
f.write('\n'.join(audios))
if not no_subtitles:
subtitles_file = str(output_dir / f"{title}.subs")
with open(subtitles_file, 'w') as f:
subtitles = [f"{c}: {l}"
for c, l in _dvd.get_title_subtitle_map(device, title)]
f.write('\n'.join(subtitles))
if not no_chapters:
chapters_file = str(output_dir / f"{title}.chapters")
with open(chapters_file, 'w') as f:
chapters = _dvd.get_title_chapters(device, title)
f.write(','.join(chapters))
@dvd.command('rip')
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('--media-type', type=click.Choice(['film', 'animation']))
@click.option('--keep-audio', is_flag=True)
@click.option('--audio-map', multiple=True, type=(int, str),
help="Keep specific audio. Order is important. Takes first "
"audio stream and puts it at stream INT in the new file "
"with language STR.")
@click.option('--subtitle-map', multiple=True, type=(int, str),
help="Keep the specific subtitles. Order is important. Takes "
"stream defined as INT into place according to order with "
"language STR.")
@click.option('--quality', type=int, default=22, show_default=True)
@click.option('--start-at-title', type=int, default=0, show_default=True)
@click.option('--end-at-title', type=int, default=-1)
@click.option('-t', '--title', 'titles', multiple=True, type=int)
@click.option('--deinterlace', is_flag=True)
@click.option('--pre-read-size', type=int, default=1024, show_default=True)
@click.option('--ffmpeg-opts')
@pass_device
def dvd_rip(output_dir, titles, device, media_type, keep_audio, quality,
start_at_title, end_at_title, ffmpeg_opts, deinterlace, audio_map,
subtitle_map, pre_read_size):
_rip(_dvd, output_dir, titles, device, media_type, keep_audio, quality,
start_at_title, end_at_title, ffmpeg_opts, deinterlace, audio_map,
subtitle_map, pre_read_size)
@dvd.command('rip-audio')
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-t', '--title', type=int, required=True)
@pass_device
def dvd_rip_audio(device, output_dir, title):
"""Rip the given title's audio from DVD"""
def partial(output_file, input_file):
return ['ffmpeg',
'-i', input_file,
'-map', '0:a:0',
'-c:a', 'flac',
output_file]
mpv_cmd = _dvd.get_parameterized_mpv_dump_cmd(device)
utils.rip_title(title, output_dir, mpv_cmd, partial,
output_file_suffix='.flac')
@dvd.command('find-right-title')
@click.option('--start-title', type=int, default=0, show_default=True)
@click.option('--end-title', type=int)
@pass_device
def dvd_find_right_title(start_title, end_title, device):
"""Try to find a title without duplicate chapter lengths"""
if end_title and start_title > end_title:
msg = '--start-title must not be bigger than --end-title'
raise click.ClickException(msg)
titles = _dvd.get_num_titles(device)
if start_title > titles:
raise RuntimeError('DVD has less titles than start title.')
if end_title:
end_title = min(titles - 1, end_title)
else:
end_title = titles - 1
title_chapter_lengths = {}
for title in range(start_title, end_title + 1):
chapters = _dvd.get_title_chapters(device, title)
timestamps = []
for timestamp in chapters:
timestamp, microseconds = timestamp.split('.')
dt = datetime.strptime(timestamp, '%H:%M:%S')
dt = dt.replace(microsecond=int(microseconds) * 1000)
timestamps.append(dt)
lengths = []
if len(timestamps) > 1:
for i, timestamp in enumerate(timestamps[1:]):
lengths.append(timestamp - timestamps[i])
if not lengths:
continue
title_chapter_lengths[title] = lengths
# remove titles with duplicate chapters by looking at the length
title_chapter_lengths = {
title: lengths for title, lengths in title_chapter_lengths.items()
if len(lengths) == len(set(lengths))}
if title_chapter_lengths:
print('The titles {} contain no duplicate chapter lengths.'.format(
', '.join(str(i) for i in title_chapter_lengths.keys())))
else:
print('No title without duplicates found.')
@cli.group()
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE,
default='/dev/dvd', show_default=True)
@click.pass_context
def bluray(ctx, device):
ctx.ensure_object(dict)
ctx.obj['device'] = str(device)
@bluray.group('info')
def bluray_info():
...
@bluray_info.command('audios')
@click.option('-t', '--title', type=int, required=True)
@pass_device
def bluray_info_audios(device, title):
"""Show the audio streams and their languages for a title"""
for i, (channel, lang) in enumerate(_bluray.get_title_audio_map(device, title)):
print(f"{i}: {lang} aid probably {channel} (0x{channel:x})")
@bluray_info.command('chapters')
@click.option('-t', '--title', type=int, required=True)
@pass_device
def bluray_info_chapters(title, device):
"""Show the chapters for a title"""
print(','.join(_bluray.get_title_chapters(device, title)))
@bluray_info.command('ffprobe')
@click.option('-t', '--title', type=int, required=True)
@pass_device
def bluray_info_ffprobe(title, device):
"""Run ffprobe on the given title
Output is saved into a file named like the title number in the given directory.
"""
# ffmpeg_cmd = [['ffprobe', '-probesize', '1024M', '-analyzeduration', '1024M', '-i']]
ffmpeg_cmd = [['ffprobe', '-i']]
def parameterized(output_file, input_file):
return ffmpeg_cmd[0] + [input_file]
mpv_cmd = _bluray.get_parameterized_mpv_dump_cmd(device)
utils.rip_title(title, Path('/tmp'), mpv_cmd, parameterized)
@bluray_info.command('main-title')
@pass_device
def bluray_info_main_title(device):
"""Show the main title as advertised by the disk"""
title, playlist = _bluray.get_main_title(device)
print(f"Main title: {title} with playlist {playlist}")
@bluray_info.command('subtitles')
@click.option('-t', '--title', type=int, required=True)
@pass_device
def bluray_info_subtitles(device, title):
"""Show the subtitle streams and their languages for a title"""
for i, (channel, lang) in enumerate(_bluray.get_title_subtitle_map(device, title)):
print(f"{i}: {lang} with id {channel} (0x{channel:x})")
@bluray_info.command('titles')
@pass_device
def bluray_info_titles(device):
"""Show the titles and their length"""
for i, t in enumerate(_bluray.get_titles(device)):
print(f"{i}: {t}")
@bluray.command('rip')
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('--media-type', type=click.Choice(['film', 'animation']))
@click.option('--keep-audio', is_flag=True)
@click.option('--audio-map', multiple=True, type=(int, str),
help="Keep specific audio. Order is important. Takes first "
"audio stream and puts it at stream INT in the new file "
"with language STR.")
@click.option('--subtitle-map', multiple=True, type=(int, str),
help="Keep the specific subtitles. Order is important. Takes "
"stream defined as INT into place according to order with "
"language STR.")
@click.option('--quality', type=int, default=22, show_default=True)
@click.option('--start-at-title', type=int, default=0, show_default=True)
@click.option('--end-at-title', type=int, default=-1)
@click.option('-t', '--title', 'titles', multiple=True, type=int)
@click.option('--deinterlace', is_flag=True)
@click.option('--pre-read-size', type=int, default=1024, show_default=True)
@click.option('--ffmpeg-opts')
@click.option('--scale', type=click.Choice(['720p', '480p']), callback=scale_convert,
help='Re-scale the video to the given size')
@pass_device
def bluray_rip(output_dir, titles, device, media_type, keep_audio, quality,
start_at_title, end_at_title, ffmpeg_opts, deinterlace,
audio_map, subtitle_map, pre_read_size, scale):
_rip(_bluray, output_dir, titles, device, media_type, keep_audio, quality,
start_at_title, end_at_title, ffmpeg_opts, deinterlace, audio_map,
subtitle_map, pre_read_size, scale=scale)
@bluray.command('rip-audio')
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-t', '--title', type=int, required=True)
@pass_device
def bluray_rip_audio(device, output_dir, title):
"""Rip the given title's audio from DVD"""
def partial(output_file, input_file):
return ['ffmpeg',
'-i', input_file,
'-map', '0:a:0',
'-c:a', 'flac',
output_file]
mpv_cmd = _bluray.get_parameterized_mpv_dump_cmd(device)
utils.rip_title(title, output_dir, mpv_cmd, partial,
output_file_suffix='.flac')
@cli.group()
def video():
...
@video.command('convert')
@click.option('-i', '--input-file', type=FILE_TYPE, required=True)
@click.option('-o', '--output-file', type=NEW_FILE_TYPE, required=True)
@click.option('--media-type', type=click.Choice(['film', 'animation']))
@click.option('--quality', type=int, default=18, show_default=True)
@click.option('--deinterlace', is_flag=True)
@click.option('--ffmpeg-opts')
@click.option('--scale', type=click.Choice(['720p', '480p']), callback=scale_convert,
help='Re-scale the video to the given size')
@click.option('--audio-map', multiple=True, type=(int, str),
help="Keep specific audio. Order is important. Takes first "
"audio stream and puts it at stream INT in the new file "
"with language STR.")
@click.option('--subtitle-map', multiple=True, type=(int, str),
help="Keep the specific subtitles. Order is important. Takes "
"stream defined as INT into place according to order with "
"language STR.")
@click.option('-c', '--chapters')
def video_convert(input_file, output_file, media_type, quality, deinterlace,
ffmpeg_opts, audio_map, subtitle_map, chapters, scale):
ffmpeg_opts = shlex.split(ffmpeg_opts) if ffmpeg_opts is None else []
video_filters = []
if deinterlace:
video_filters.append('yadif')
if chapters:
chapters = chapters.split(',')
ffmpeg_cmd = utils.get_ffmpeg_cmd(output_file, input_file, media_type,
quality, ffmpeg_opts, False, audio_map,
subtitle_map, video_filters=video_filters,
scale=scale)
subprocess.check_call(ffmpeg_cmd)
if chapters:
utils.set_chapters(output_file, chapters)
@cli.command('set-chapters')
@click.option('-o', '--output-file', type=FILE_TYPE, required=True)
@click.option('-c', '--chapters', type=FILE_TYPE, required=True,
help='File containing a comma-separated list of timestamps')
def set_chapters(output_file, chapters):
"""Set chapters in given file according to the timestamps in the chapters file"""
chapters = chapters.split(',')
utils.set_chapters(output_file, chapters)
@video.command('split-file')
@click.option('-c', '--chapters-file', type=FILE_TYPE, required=True,
help='File with timestamps separated by comma')
@click.option('-i', '--input-file', type=FILE_TYPE, required=True)
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-n', '--number', type=int,
help='Split out only one chapter given by this number.')
@click.option('--reencode', is_flag=True,
help='Don\'t use -c:v copy and -c:a copy')
@click.option('--media-type', type=click.Choice(['film', 'animation']),
help='Only relevant with --reencode')
@click.option('--quality', type=int, default=18, show_default=True,
help='Only relevant with --reencode')
@click.option('--ffmpeg-opts',
help='Only relevant with --reencode')
@click.option('--scale', type=click.Choice(['720p', '480p']), callback=scale_convert,
help='Re-scale the video to the given size')
@click.option('--audio-map', multiple=True, type=(int, str),
help="Keep specific audio. Only relevant with --reencode."
"Order is important. Takes first "
"audio stream and puts it at stream INT in the new file "
"with language STR.")
@click.option('--subtitle-map', multiple=True, type=(int, str),
help="Keep the specific subtitles. Order is important. Takes "
"stream defined as INT into place according to order with "
"language STR.")
def utils_split_file(output_dir, input_file, chapters_file, number, reencode,
media_type, quality, ffmpeg_opts, audio_map, subtitle_map,
scale):
if ffmpeg_opts is None:
ffmpeg_opts = []
else:
ffmpeg_opts = shlex.split(ffmpeg_opts)
extension = input_file.split('.')[-1]
with open(chapters_file) as f:
chapters = f.read().strip().split(',')
for i, (start, end) in enumerate(itertools.zip_longest(chapters, chapters[1:]), start=1):
if number is not None and i != number:
continue
extra_opts = ffmpeg_opts + ['-ss', start]
if end is not None:
extra_opts.extend(['-to', end])
out_path = output_dir / f"{i}.{extension}"
if not reencode:
ffmpeg_cmd = ['ffmpeg'] + extra_opts + [
'-i', input_file,
'-c:a', 'copy',
'-c:v', 'copy',
'-movflags', '+faststart',
str(out_path)
]
else:
use_gpu = False
ffmpeg_cmd = utils.get_ffmpeg_cmd(out_path, input_file, media_type,
quality, extra_opts, use_gpu,
audio_map, subtitle_map,
scale=scale)
subprocess.check_call(ffmpeg_cmd)
@cli.group()
def audio():
...
@audio.command('split-file')
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
@click.option('-c', '--chapters-file', type=FILE_TYPE, required=True,
help='File with timestamps separated by comma')
@click.option('-i', '--input-file', type=FILE_TYPE, required=True)
def audio_split_file(output_dir, chapters_file, input_file):
"""Split an audio file into different flac files based on timestamps in the chapters file"""
with open(chapters_file) as f:
chapters = f.read().strip().split(',')
times_ten = 0
while len(chapters) // (10 ** times_ten) > 9:
times_ten += 1
name_template = '{:0' + str(times_ten + 1) + 'd}.flac'
for i, (start, end) in enumerate(itertools.zip_longest(chapters, chapters[1:]), start=1):
ffmpeg_cmd = [
'ffmpeg',
'-ss', start]
if end is not None:
ffmpeg_cmd.extend(['-to', end])
out_path = output_dir / name_template.format(i)
ffmpeg_cmd.extend([
'-i', input_file,
'-c:a', 'flac',
'-movflags', '+faststart',
str(out_path)])
subprocess.check_call(ffmpeg_cmd)
if __name__ == '__main__':
cli(complete_var='_RIP_PY_COMPLETE')

192
rip-py/rip_py/utils.py Normal file
View File

@ -0,0 +1,192 @@
import math
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
def with_fifo(func):
def f(*args, **kwargs):
with tempfile.TemporaryDirectory() as tmpdir:
fifo_path = str(Path(tmpdir) / 'rip.fifo')
try:
os.mkfifo(fifo_path)
except FileExistsError:
pass
kwargs['fifo_path'] = fifo_path
return func(*args, **kwargs)
return f
def convert_chapters_to_mkvmerge_format(chapters):
"""Convert a list of chapter timestamps into a string (of multiple lines) as accepted by mkvmerge"""
# determine how many zeroes we have as a prefix
number_of_zeros = 0
if len(chapters) > 1:
number_of_zeros = int(math.log10(len(chapters) - 1))
chapter_template = 'CHAPTER{{:0{}d}}'.format(number_of_zeros)
lines = []
for i, timestamp in enumerate(chapters):
chapter_str = chapter_template.format(i)
# if we start with e.g. 0:21, we need another 0 to be compatible
if len(timestamp.split(':')[0]) == 1:
timestamp = '0' + timestamp
# if we have a higher resolution than 1 millisecond, we need to cut
# some chars
if len(timestamp.split('.')[-1]) > 3:
strip_length = len(timestamp.split('.')[-1]) - 3
timestamp = timestamp[:-strip_length]
lines.append(f"{chapter_str}={timestamp}")
lines.append(f"{chapter_str}NAME=")
return os.linesep.join(lines)
def set_chapters(mkv_file, chapters):
"""Set the chapters in the given MKV file
`chapters` is a list of chapter start times.
"""
if not Path(mkv_file).exists():
raise RuntimeError('MKV file does not exist.')
if not shutil.which('mkvpropedit'):
raise RuntimeError('Cannot find "mkvpropedit". Please install the "mkvtoolnix" package.')
with tempfile.NamedTemporaryFile() as f:
mkvmerge_chapters = convert_chapters_to_mkvmerge_format(chapters)
f.write(mkvmerge_chapters.encode('utf-8'))
f.flush()
mkvpropedit_cmd = ['mkvpropedit', mkv_file, '--chapters', f.name]
subprocess.check_call(mkvpropedit_cmd)
def get_parameterized_ffmpeg_cmd(media_type, quality, extra_opts, use_gpu,
audio_map, subtitle_map, pre_read=1024,
video_filters=None, scale=None):
"""Build an ffmpeg command based on the given parameters
Returns an function taking input file and output file as arguments to build
the final command.
"""
if scale is not None:
if video_filters is None:
video_filters = []
video_filters.append(f"scale={scale}")
ffmpeg_cmd = []
ffmpeg_cmd.append(['ffmpeg',
'-probesize', '{}M'.format(pre_read),
'-analyzeduration', '{}M'.format(pre_read),
'-hwaccel', 'auto',
'-i'])
ffmpeg_cmd.append(extra_opts)
ffmpeg_cmd[-1].extend(['-map', '0:v:0'])
if '-c:a' not in ffmpeg_cmd[-1]:
ffmpeg_cmd[-1].extend(['-c:a', 'copy'])
ffmpeg_cmd[-1].extend(['-c:s', 'copy'])
if use_gpu:
ffmpeg_cmd[-1] += [
'-c:v', 'h264_nvenc',
'-rc', 'vbr_hq',
'-rc-lookahead', '20',
'-cq', '{}'.format(quality),
# '-maxrate:v', '3M',
# '-b:v', '8M', '-maxrate:v', '10M',
'-preset:v', 'slow']
else:
ffmpeg_cmd[-1] += [
'-c:v', 'h264',
'-crf', '{}'.format(quality),
'-preset:v', 'slower',
'-x264-params', 'opencl=true',
# '-x264-params', 'keyint=240:min-keyint=20',
]
ffmpeg_cmd[-1] += [
'-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
# '-profile:v', 'baseline',
# '-level', '3.0',
]
if video_filters:
ffmpeg_cmd[-1] += [
'-vf', ','.join(video_filters)
]
if audio_map:
for i, (channel, lang) in enumerate(audio_map):
if channel < 0x80:
map_target = '0:a:{}'.format(channel)
else:
map_target = 'i:0x{:x}'.format(channel)
ffmpeg_cmd[-1].extend([
'-map', map_target,
'-metadata:s:a:{}'.format(i), 'language={}'.format(lang)
])
else:
ffmpeg_cmd[-1].extend(['-map', '0:a:0', '-metadata:s:a:0', 'language=de'])
if subtitle_map:
for i, (channel, lang) in enumerate(subtitle_map):
if channel < 0x20:
map_target = '0:s:{}'.format(channel)
else:
map_target = 'i:0x{:x}'.format(channel)
ffmpeg_cmd[-1].extend([
'-map', map_target,
'-metadata:s:s:{}'.format(i), 'language={}'.format(lang)
])
# TODO we are probably able to do forced subtitles. we probably want that
# we can set a default stream. make this available somehow
# -disposition:a:1 default
if media_type and not use_gpu:
ffmpeg_cmd[-1].extend(['-tune:v', media_type])
def parameterized(output_file, input_file):
return ffmpeg_cmd[0] + [input_file] + ffmpeg_cmd[1] + [output_file]
return parameterized
def get_ffmpeg_cmd(output_file, input_path, media_type, quality, extra_opts,
use_gpu, audio_map, subtitle_map, video_filters=None,
scale=None):
cmd = get_parameterized_ffmpeg_cmd(media_type, quality, extra_opts,
use_gpu, audio_map, subtitle_map,
video_filters=video_filters, scale=scale)
cmd = cmd(output_file=output_file, input_file=input_path)
print(cmd)
return cmd
@with_fifo
def rip_title(title, output_dir, mpv_cmd, ffmpeg_cmd, fifo_path,
output_file_suffix='.mkv'):
filename = f"{title}{output_file_suffix}"
out_file = output_dir / filename
mpv_cmd = mpv_cmd(title, fifo_path)
# we need to redirect stdout, because otherwise we endup botching the terminal input somehow
mpv_proc = subprocess.Popen(mpv_cmd, stdout=subprocess.DEVNULL)
ffmpeg_cmd = ffmpeg_cmd(output_file=out_file, input_file=fifo_path)
print(ffmpeg_cmd)
subprocess.check_call(ffmpeg_cmd)
# mpv should exit if ffmpeg is gone, because the FIFO got closed
mpv_proc.communicate()
return out_file

View File

@ -4,9 +4,16 @@ version = 0.1
description = CD/DVD/BluRay ripping and re-encoding toolkit description = CD/DVD/BluRay ripping and re-encoding toolkit
[options] [options]
scripts = rip.py packages = find:
install_requires = install_requires =
click click
dvdread @ git+https://github.com/MasterofJOKers/PyDvdRead@pr-get-right-subpicture
bluread @ git+https://github.com/cmlburnett/PyBluRead.git
crudexml # PyBluRead does not contain this dependency it has
[options.entry_points]
console_scripts =
rip.py = rip_py.cli:cli
[flake8] [flake8]