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:
parent
cf53e5dd3d
commit
d17af01e2f
813
rip-py/rip.py
813
rip-py/rip.py
|
@ -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')
|
|
@ -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)
|
|
@ -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)
|
|
@ -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')
|
|
@ -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
|
|
@ -4,9 +4,16 @@ version = 0.1
|
|||
description = CD/DVD/BluRay ripping and re-encoding toolkit
|
||||
|
||||
[options]
|
||||
scripts = rip.py
|
||||
packages = find:
|
||||
install_requires =
|
||||
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]
|
||||
|
|
Loading…
Reference in New Issue