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')