From 074b368b8279391ba851f060f5e57b7e53e5d50d Mon Sep 17 00:00:00 2001 From: MasterofJOKers Date: Wed, 6 Dec 2023 00:04:29 +0100 Subject: [PATCH] rip-py: Add all the latest things This is the latest version having new commands and support for dvdread to reach chapters, because the mpv output was unreliable (iirc it forgot to output the last chapter) and slower and harder to parse. --- rip-py/rip.py | 662 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 579 insertions(+), 83 deletions(-) diff --git a/rip-py/rip.py b/rip-py/rip.py index 93a520a..172fb3b 100755 --- a/rip-py/rip.py +++ b/rip-py/rip.py @@ -1,18 +1,27 @@ #!/usr/bin/env python3 -from datetime import datetime +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) +DEVICE_OR_DIRECTORY_TYPE = click.Path(file_okay=True, dir_okay=True, + exists=True) @click.group() @@ -34,12 +43,14 @@ def with_fifo(func): 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', - 'dvdread://{}'.format(title)] + 'dvd://{}'.format(title)] if device: mpv_cmd.append('--dvd-device={}'.format(device)) mpv_output = subprocess.check_output(mpv_cmd, @@ -49,6 +60,10 @@ def _get_audio_map(title=None, mpv_output=None, device=None): 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: @@ -62,64 +77,209 @@ def _get_audio_map(title=None, mpv_output=None, device=None): return audio_map -@with_fifo -def _rip_title(title, output_dir, fifo_path, device=None, media_type=None, - keep_audio=False, quality=18): - out_file = os.path.join(output_dir, '{}.mkv'.format(title)) +def _get_audios(title, device): + if HAS_DVDREAD: + with dvdread.DVD(device) as d: + d.Open() - 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) + 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)) - ffmpeg_cmd = ['ffmpeg', - '-probesize', '101M', - '-analyzeduration', '150M', - '-i', fifo_path, - '-map', '0:v:0', - #'-c:a', 'flac', - '-c:a', 'copy', - '-c:v', 'h264', - '-crf', '{}'.format(quality), - '-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) + # 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.extend(['-map', '0:a:0', '-metadata:s:a:0', 'language=de']) + 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 - if media_type: - ffmpeg_cmd.extend(['-tune:v', media_type]) - ffmpeg_cmd.append(out_file) +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') - chapters = _get_title_chapters(mpv_output=mpv_output) - if chapters: - _set_chapters(out_file, chapters) + + 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', - 'dvdread://{}'.format(title)] + 'dvd://{}'.format(title)] if device: mpv_cmd.append('--dvd-device={}'.format(device)) mpv_output = subprocess.check_output(mpv_cmd, @@ -141,13 +301,21 @@ def _get_title_chapters(title=None, mpv_output=None, device=None): def _convert_chapters_to_mkvmerge_format(chapters): - lines = [] + 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) @@ -167,7 +335,21 @@ def _set_chapters(mkv_file, chapters): def _get_dvd_titles(device=None): - mpv_cmd = ['mpv', '--vo', 'null', '--quiet', '--frames=0', 'dvdread://'] + 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) @@ -189,42 +371,175 @@ def _get_dvd_titles(device=None): @cli.command() @click.option('-o', '--output-dir', type=DIRECTORY_TYPE, required=True) -@click.option('-d', '--device', type=DEVICE_OR_DIRECTORY_TYPE) +@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('--quality', type=click.INT) -def all(output_dir, device=None, media_type=None, keep_audio=False, quality=18): - 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, quality=quality) +@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']) -@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) + 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('--keep-audio', is_flag=True) -@click.option('--quality', type=click.INT) -def single(title, output_dir, device=None, media_type=None, keep_audio=False, - quality=18): - _rip_title(title, output_dir, device=device, media_type=media_type, - keep_audio=keep_audio, quality=quality) +@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) +@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) +@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.') @@ -238,12 +553,14 @@ def set_chapters(output_file, title=None, chapters=None, device=None): @cli.command('find-right-title') -@click.option('--start-title', type=int, default=0) +@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) +@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: - raise click.ClickException('--start-title must not be bigger than --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: @@ -276,8 +593,9 @@ def find_right_title(start_title, end_title, device): 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))} + 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( @@ -288,30 +606,208 @@ def find_right_title(start_title, end_title, device): @cli.command('split-file') @click.option('-c', '--chapters-file', type=FILE_TYPE, required=True, - help='File with timestamps separated by comma') + 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) -def split_file(output_dir, input_file, chapters_file): +@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:])): - ffmpeg_cmd = ['ffmpeg', - '-ss', start] + + 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: - ffmpeg_cmd += ['-to', end] + 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)) - ffmpeg_cmd += [ + +@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', - #'-c:v', 'copy', - os.path.join(output_dir, '{}.{}'.format(i + 1, extension)) - ] - #print(ffmpeg_cmd) + '-movflags', '+faststart', + out_path]) + subprocess.check_call(ffmpeg_cmd) if __name__ == '__main__': - cli() + cli(complete_var='_RIP_PY_COMPLETE')