Add rip-py project
This is a backup from 2020-08-19 - the oldest version I still have around.
This commit is contained in:
parent
ae52d4962a
commit
32edd53cfb
|
@ -0,0 +1,285 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from datetime import datetime
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
FILE_TYPE = click.Path(file_okay=True, dir_okay=False, exists=True)
|
||||||
|
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):
|
||||||
|
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',
|
||||||
|
'dvdread://{}'.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.
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@with_fifo
|
||||||
|
def _rip_title(title, output_dir, fifo_path, device=None, media_type=None,
|
||||||
|
keep_audio=False):
|
||||||
|
out_file = os.path.join(output_dir, '{}.mkv'.format(title))
|
||||||
|
|
||||||
|
mpv_cmd = ['mpv', '--quiet', '--stream-dump={}'.format(fifo_path),
|
||||||
|
'dvdread://{}'.format(title)]
|
||||||
|
if device:
|
||||||
|
mpv_cmd.append('--dvd-device={}'.format(device))
|
||||||
|
mpv_proc = subprocess.Popen(mpv_cmd, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT)
|
||||||
|
|
||||||
|
ffmpeg_cmd = ['ffmpeg',
|
||||||
|
'-probesize', '101M',
|
||||||
|
'-analyzeduration', '150M',
|
||||||
|
'-i', fifo_path,
|
||||||
|
'-map', '0:v:0',
|
||||||
|
'-c:a', 'copy',
|
||||||
|
'-c:v', 'h264',
|
||||||
|
'-crf', '18',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
#'-x264-params', 'keyint=240:min-keyint=20',
|
||||||
|
'-preset:v', 'slower',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
#'-profile:v', 'baseline',
|
||||||
|
#'-level', '3.0',
|
||||||
|
]
|
||||||
|
|
||||||
|
if keep_audio:
|
||||||
|
audio_map = _get_audio_map(title, device=device)
|
||||||
|
for channel, lang in audio_map:
|
||||||
|
ffmpeg_cmd.extend([
|
||||||
|
'-map', '0:a:{}'.format(channel),
|
||||||
|
'-metadata:s:a:{}'.format(channel), 'language={}'.format(lang)
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
ffmpeg_cmd.extend(['-map', '0:a:0', '-metadata:s:a:0', 'language=de'])
|
||||||
|
|
||||||
|
if media_type:
|
||||||
|
ffmpeg_cmd.extend(['-tune:v', media_type])
|
||||||
|
|
||||||
|
ffmpeg_cmd.append(out_file)
|
||||||
|
subprocess.check_call(ffmpeg_cmd)
|
||||||
|
|
||||||
|
mpv_output = mpv_proc.stdout.read().decode('utf-8')
|
||||||
|
chapters = _get_title_chapters(mpv_output=mpv_output)
|
||||||
|
if chapters:
|
||||||
|
_set_chapters(out_file, chapters)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_title_chapters(title=None, mpv_output=None, device=None):
|
||||||
|
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',
|
||||||
|
'dvdread://{}'.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)
|
||||||
|
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):
|
||||||
|
mpv_cmd = ['mpv', '--vo', 'null', '--quiet', '--frames=0', 'dvdread://']
|
||||||
|
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)
|
||||||
|
@click.option('--media-type', type=click.Choice(['film', 'animation']))
|
||||||
|
@click.option('--keep-audio', is_flag=True)
|
||||||
|
def all(output_dir, device=None, media_type=None, keep_audio=False):
|
||||||
|
titles = _get_dvd_titles(device=device)
|
||||||
|
for title in range(0, titles):
|
||||||
|
_rip_title(title, output_dir, device=device, media_type=media_type,
|
||||||
|
keep_audio=keep_audio)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('-t', '--title', type=int, required=True)
|
||||||
|
@click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True)
|
||||||
|
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE)
|
||||||
|
@click.option('--media-type', type=click.Choice(['film', 'animation']))
|
||||||
|
@click.option('--keep-audio', is_flag=True)
|
||||||
|
def single(title, output_dir, device=None, media_type=None, keep_audio=False):
|
||||||
|
_rip_title(title, output_dir, device=device, media_type=media_type,
|
||||||
|
keep_audio=keep_audio)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command('get-chapters')
|
||||||
|
@click.option('-t', '--title', type=int, required=True)
|
||||||
|
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE)
|
||||||
|
def get_chapters(title, device=None):
|
||||||
|
print(','.join(_get_title_chapters(title=title, 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)
|
||||||
|
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)
|
||||||
|
@click.option('--end-title', type=int)
|
||||||
|
@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE)
|
||||||
|
def find_right_title(start_title, end_title, device):
|
||||||
|
if end_title and start_title > end_title:
|
||||||
|
raise click.ClickException('--start-title must not be bigger than --end-title')
|
||||||
|
|
||||||
|
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.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cli()
|
|
@ -0,0 +1,15 @@
|
||||||
|
[metadata]
|
||||||
|
name = rip-py
|
||||||
|
version = 0.1
|
||||||
|
description = CD/DVD/BluRay ripping and re-encoding toolkit
|
||||||
|
|
||||||
|
[options]
|
||||||
|
scripts = rip.py
|
||||||
|
install_requires =
|
||||||
|
click
|
||||||
|
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
max-line-length = 120
|
||||||
|
exclude = .git,__pycache__,*.egg-info,*lib/python*
|
||||||
|
ignore = E241,E741,W503,W504
|
|
@ -0,0 +1,4 @@
|
||||||
|
import setuptools
|
||||||
|
|
||||||
|
|
||||||
|
setuptools.setup()
|
Loading…
Reference in New Issue