Compare commits

...

6 Commits

Author SHA1 Message Date
MasterofJOKers d17af01e2f rip-py: Split things and support Blu-ray
We're using a `libbluray2` wrapper by the same person that also built
`pydvdread`. At the same time, we dump supporting to parse `mpv` output,
because that was too unreliable anyways and keeping it just means more
maintenance overhead.

To provide a better overview, we've split up the DVD and Blu-ray
functionality into own modules
2024-01-05 22:32:55 +01:00
MasterofJOKers cf53e5dd3d rip-py: Fix leftover PEP8 errors 2023-12-06 00:06:56 +01:00
MasterofJOKers 074b368b82 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.
2023-12-06 00:04:29 +01:00
MasterofJOKers a95febccc3 rip-py: Support overriding the quality
This is a backup from 2021-01-01 which adds the `--quality` option to
the `all` and `single` commands.
2023-12-05 23:54:12 +01:00
MasterofJOKers 37f03fe6bd rip-py: Add a split-file command
This is a backup from 2020-11-29 which added a split-file command to the
toolkit, which can be used to e.g. split DVDs of concerts into its
songs.
2023-12-05 23:51:35 +01:00
MasterofJOKers 32edd53cfb Add rip-py project
This is a backup from 2020-08-19 - the oldest version I still have
around.
2023-12-05 23:50:04 +01:00
6 changed files with 1079 additions and 0 deletions

115
rip-py/rip_py/_bluray.py Normal file
View File

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

128
rip-py/rip_py/_dvd.py Normal file
View File

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

618
rip-py/rip_py/cli.py Normal file
View File

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

192
rip-py/rip_py/utils.py Normal file
View File

@ -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

22
rip-py/setup.cfg Normal file
View File

@ -0,0 +1,22 @@
[metadata]
name = rip-py
version = 0.1
description = CD/DVD/BluRay ripping and re-encoding toolkit
[options]
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]
max-line-length = 120
exclude = .git,__pycache__,*.egg-info,*lib/python*
ignore = E241,E741,W503,W504

4
rip-py/setup.py Normal file
View File

@ -0,0 +1,4 @@
import setuptools
setuptools.setup()