diff --git a/rip-py/rip.py b/rip-py/rip.py deleted file mode 100755 index 60165ec..0000000 --- a/rip-py/rip.py +++ /dev/null @@ -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') diff --git a/rip-py/rip_py/_bluray.py b/rip-py/rip_py/_bluray.py new file mode 100644 index 0000000..bd782a5 --- /dev/null +++ b/rip-py/rip_py/_bluray.py @@ -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) diff --git a/rip-py/rip_py/_dvd.py b/rip-py/rip_py/_dvd.py new file mode 100644 index 0000000..5ebabd9 --- /dev/null +++ b/rip-py/rip_py/_dvd.py @@ -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) diff --git a/rip-py/rip_py/cli.py b/rip-py/rip_py/cli.py new file mode 100644 index 0000000..b30e30d --- /dev/null +++ b/rip-py/rip_py/cli.py @@ -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') diff --git a/rip-py/rip_py/utils.py b/rip-py/rip_py/utils.py new file mode 100644 index 0000000..74e8416 --- /dev/null +++ b/rip-py/rip_py/utils.py @@ -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 diff --git a/rip-py/setup.cfg b/rip-py/setup.cfg index 10a024d..fb6add5 100644 --- a/rip-py/setup.cfg +++ b/rip-py/setup.cfg @@ -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]