#!/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')