From 32edd53cfbfab58c22b52aacb007055acb3a3bcf Mon Sep 17 00:00:00 2001 From: MasterofJOKers Date: Tue, 5 Dec 2023 23:50:04 +0100 Subject: [PATCH] Add rip-py project This is a backup from 2020-08-19 - the oldest version I still have around. --- rip-py/rip.py | 285 +++++++++++++++++++++++++++++++++++++++++++++++ rip-py/setup.cfg | 15 +++ rip-py/setup.py | 4 + 3 files changed, 304 insertions(+) create mode 100755 rip-py/rip.py create mode 100644 rip-py/setup.cfg create mode 100644 rip-py/setup.py diff --git a/rip-py/rip.py b/rip-py/rip.py new file mode 100755 index 0000000..edab12c --- /dev/null +++ b/rip-py/rip.py @@ -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() diff --git a/rip-py/setup.cfg b/rip-py/setup.cfg new file mode 100644 index 0000000..10a024d --- /dev/null +++ b/rip-py/setup.cfg @@ -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 diff --git a/rip-py/setup.py b/rip-py/setup.py new file mode 100644 index 0000000..056ba45 --- /dev/null +++ b/rip-py/setup.py @@ -0,0 +1,4 @@ +import setuptools + + +setuptools.setup()