|
|
@ -362,6 +362,32 @@ def generateDispositionTokens(subDescriptor):
|
|
|
|
return dispositionTokens
|
|
|
|
return dispositionTokens
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def searchSubtitleFiles(dir, prefix):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sesl_match = re.compile(SEASON_EPISODE_STREAM_LANGUAGE_MATCH)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
availableFileSubtitles = []
|
|
|
|
|
|
|
|
for subtitleFilename in os.listdir(dir):
|
|
|
|
|
|
|
|
if subtitleFilename.startswith(prefix) and subtitleFilename.endswith('.' + SUBTITLE_FILE_EXTENSION):
|
|
|
|
|
|
|
|
sesl_result = sesl_match.search(subtitleFilename)
|
|
|
|
|
|
|
|
if sesl_result is not None:
|
|
|
|
|
|
|
|
subtitleFilePath = os.path.join(dir, subtitleFilename)
|
|
|
|
|
|
|
|
if os.path.isfile(subtitleFilePath):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
subtitleFileDescriptor = {}
|
|
|
|
|
|
|
|
subtitleFileDescriptor['path'] = subtitleFilePath
|
|
|
|
|
|
|
|
subtitleFileDescriptor['season'] = int(sesl_result.group(1))
|
|
|
|
|
|
|
|
subtitleFileDescriptor['episode'] = int(sesl_result.group(2))
|
|
|
|
|
|
|
|
subtitleFileDescriptor['stream'] = int(sesl_result.group(3))
|
|
|
|
|
|
|
|
subtitleFileDescriptor['language'] = sesl_result.group(4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
availableFileSubtitles.append(subtitleFileDescriptor)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
click.echo(f"Found {len(availableFileSubtitles)} subtitles in files\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return availableFileSubtitles
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@click.group()
|
|
|
|
@click.group()
|
|
|
|
@click.pass_context
|
|
|
|
@click.pass_context
|
|
|
|
def ffx(ctx):
|
|
|
|
def ffx(ctx):
|
|
|
@ -434,7 +460,7 @@ def streams(filename):
|
|
|
|
@click.option("-c", "--clear-metadata", is_flag=True, default=False)
|
|
|
|
@click.option("-c", "--clear-metadata", is_flag=True, default=False)
|
|
|
|
@click.option("-d", "--denoise", is_flag=True, default=False)
|
|
|
|
@click.option("-d", "--denoise", is_flag=True, default=False)
|
|
|
|
|
|
|
|
|
|
|
|
@click.option("-j", "--no-jellyfin-tweaks", is_flag=True, default=False)
|
|
|
|
@click.option("-j", "--jellyfin", is_flag=True, default=False)
|
|
|
|
|
|
|
|
|
|
|
|
@click.option("--dry-run", is_flag=True, default=False)
|
|
|
|
@click.option("--dry-run", is_flag=True, default=False)
|
|
|
|
|
|
|
|
|
|
|
@ -461,7 +487,7 @@ def convert(ctx,
|
|
|
|
output_directory,
|
|
|
|
output_directory,
|
|
|
|
clear_metadata,
|
|
|
|
clear_metadata,
|
|
|
|
denoise,
|
|
|
|
denoise,
|
|
|
|
no_jellyfin_tweaks,
|
|
|
|
jellyfin,
|
|
|
|
dry_run):
|
|
|
|
dry_run):
|
|
|
|
"""Batch conversion of audiovideo files in format suitable for web playback, e.g. jellyfin
|
|
|
|
"""Batch conversion of audiovideo files in format suitable for web playback, e.g. jellyfin
|
|
|
|
|
|
|
|
|
|
|
@ -471,10 +497,10 @@ def convert(ctx,
|
|
|
|
or if the filename has not changed."""
|
|
|
|
or if the filename has not changed."""
|
|
|
|
|
|
|
|
|
|
|
|
startTime = time.perf_counter()
|
|
|
|
startTime = time.perf_counter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
context = ctx.obj
|
|
|
|
context = ctx.obj
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
click.echo(f"\nVideo encoder: {video_encoder}")
|
|
|
|
click.echo(f"\nVideo encoder: {video_encoder}")
|
|
|
|
|
|
|
|
|
|
|
|
qualityTokens = quality.split(',')
|
|
|
|
qualityTokens = quality.split(',')
|
|
|
@ -482,7 +508,6 @@ def convert(ctx,
|
|
|
|
|
|
|
|
|
|
|
|
click.echo(f"Qualities: {q_list}")
|
|
|
|
click.echo(f"Qualities: {q_list}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
context['bitrates'] = {}
|
|
|
|
context['bitrates'] = {}
|
|
|
|
context['bitrates']['stereo'] = str(stereo_bitrate) if str(stereo_bitrate).endswith('k') else f"{stereo_bitrate}k"
|
|
|
|
context['bitrates']['stereo'] = str(stereo_bitrate) if str(stereo_bitrate).endswith('k') else f"{stereo_bitrate}k"
|
|
|
|
context['bitrates']['ac3'] = str(ac3_bitrate) if str(ac3_bitrate).endswith('k') else f"{ac3_bitrate}k"
|
|
|
|
context['bitrates']['ac3'] = str(ac3_bitrate) if str(ac3_bitrate).endswith('k') else f"{ac3_bitrate}k"
|
|
|
@ -492,67 +517,45 @@ def convert(ctx,
|
|
|
|
click.echo(f"AC3 bitrate: {context['bitrates']['ac3']}")
|
|
|
|
click.echo(f"AC3 bitrate: {context['bitrates']['ac3']}")
|
|
|
|
click.echo(f"DTS bitrate: {context['bitrates']['dts']}")
|
|
|
|
click.echo(f"DTS bitrate: {context['bitrates']['dts']}")
|
|
|
|
|
|
|
|
|
|
|
|
# Parse subtitle files
|
|
|
|
|
|
|
|
context['import_subtitles'] = (subtitle_directory and subtitle_prefix)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
se_match = re.compile(SEASON_EPISODE_INDICATOR_MATCH)
|
|
|
|
se_match = re.compile(SEASON_EPISODE_INDICATOR_MATCH)
|
|
|
|
e_match = re.compile(EPISODE_INDICATOR_MATCH)
|
|
|
|
e_match = re.compile(EPISODE_INDICATOR_MATCH)
|
|
|
|
sesl_match = re.compile(SEASON_EPISODE_STREAM_LANGUAGE_MATCH)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
availableFileSubtitles = []
|
|
|
|
# Parse subtitle files
|
|
|
|
if context['import_subtitles']:
|
|
|
|
context['import_subtitles'] = (subtitle_directory and subtitle_prefix)
|
|
|
|
for subtitleFilename in os.listdir(subtitle_directory):
|
|
|
|
availableFileSubtitles = searchSubtitleFiles(subtitle_directory, subtitle_prefix) if context['import_subtitles'] else []
|
|
|
|
if subtitleFilename.startswith(subtitle_prefix) and subtitleFilename.endswith('.' + SUBTITLE_FILE_EXTENSION):
|
|
|
|
|
|
|
|
sesl_result = sesl_match.search(subtitleFilename)
|
|
|
|
|
|
|
|
if sesl_result is not None:
|
|
|
|
|
|
|
|
subtitleFilePath = os.path.join(subtitle_directory, subtitleFilename)
|
|
|
|
|
|
|
|
if os.path.isfile(subtitleFilePath):
|
|
|
|
|
|
|
|
subtitleFileDescriptor = {}
|
|
|
|
|
|
|
|
subtitleFileDescriptor['path'] = subtitleFilePath
|
|
|
|
|
|
|
|
subtitleFileDescriptor['season'] = int(sesl_result.group(1))
|
|
|
|
|
|
|
|
subtitleFileDescriptor['episode'] = int(sesl_result.group(2))
|
|
|
|
|
|
|
|
subtitleFileDescriptor['stream'] = int(sesl_result.group(3))
|
|
|
|
|
|
|
|
subtitleFileDescriptor['language'] = sesl_result.group(4)
|
|
|
|
|
|
|
|
availableFileSubtitles.append(subtitleFileDescriptor)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
click.echo(f"Found {len(availableFileSubtitles)} subtitles in files")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
subtitleLanguages = subtitle_language
|
|
|
|
|
|
|
|
subtitleTitles = subtitle_title
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Overwrite audio tags if set
|
|
|
|
audioLanguages = audio_language
|
|
|
|
audioLanguages = audio_language
|
|
|
|
audioTitles = audio_title
|
|
|
|
audioTitles = audio_title
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Overwrite subtitle tags if set
|
|
|
|
|
|
|
|
subtitleLanguages = subtitle_language
|
|
|
|
|
|
|
|
subtitleTitles = subtitle_title
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Process crop parameters
|
|
|
|
# Process crop parameters
|
|
|
|
context['perform_crop'] = (crop != 'none')
|
|
|
|
context['perform_crop'] = (crop != 'none')
|
|
|
|
|
|
|
|
|
|
|
|
if context['perform_crop']:
|
|
|
|
if context['perform_crop']:
|
|
|
|
|
|
|
|
|
|
|
|
cropTokens = crop.split(',')
|
|
|
|
cropTokens = crop.split(',')
|
|
|
|
|
|
|
|
|
|
|
|
if cropTokens and len(cropTokens) == 2:
|
|
|
|
if cropTokens and len(cropTokens) == 2:
|
|
|
|
|
|
|
|
|
|
|
|
context['crop_start'], context['crop_length'] = crop.split(',')
|
|
|
|
context['crop_start'], context['crop_length'] = crop.split(',')
|
|
|
|
else:
|
|
|
|
else:
|
|
|
|
context['crop_start'] = DEFAULT_CROP_START
|
|
|
|
context['crop_start'] = DEFAULT_CROP_START
|
|
|
|
context['crop_length'] = DEFAULT_CROP_LENGTH
|
|
|
|
context['crop_length'] = DEFAULT_CROP_LENGTH
|
|
|
|
|
|
|
|
|
|
|
|
click.echo(f"crop start={context['crop_start']} length={context['crop_length']}")
|
|
|
|
click.echo(f"crop start={context['crop_start']} length={context['crop_length']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
existingSourcePaths = [p for p in paths if os.path.isfile(p)]
|
|
|
|
job_index = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
existingSourcePaths = [p for p in paths if os.path.isfile(p)]
|
|
|
|
click.echo(f"\nRunning {len(existingSourcePaths) * len(q_list)} jobs")
|
|
|
|
click.echo(f"\nRunning {len(existingSourcePaths) * len(q_list)} jobs")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
job_index = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for sourcePath in existingSourcePaths:
|
|
|
|
for sourcePath in existingSourcePaths:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Separate basedir, basename and extension for current source file
|
|
|
|
sourceDirectory = os.path.dirname(sourcePath)
|
|
|
|
sourceDirectory = os.path.dirname(sourcePath)
|
|
|
|
sourceFilename = os.path.basename(sourcePath)
|
|
|
|
sourceFilename = os.path.basename(sourcePath)
|
|
|
|
sourcePathTokens = sourceFilename.split('.')
|
|
|
|
sourcePathTokens = sourceFilename.split('.')
|
|
|
@ -564,9 +567,10 @@ def convert(ctx,
|
|
|
|
sourceFileBasename = sourceFilename
|
|
|
|
sourceFileBasename = sourceFilename
|
|
|
|
sourceFilenameExtension = ''
|
|
|
|
sourceFilenameExtension = ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
click.echo(f"\nProcessing file {sourcePath}")
|
|
|
|
click.echo(f"\nProcessing file {sourcePath}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Determine season and episode if present in current filename
|
|
|
|
season_digits = 2
|
|
|
|
season_digits = 2
|
|
|
|
episode_digits = 2
|
|
|
|
episode_digits = 2
|
|
|
|
index_digits = 3
|
|
|
|
index_digits = 3
|
|
|
@ -589,7 +593,7 @@ def convert(ctx,
|
|
|
|
print(f"season={season} episode={episode} file={file_index}")
|
|
|
|
print(f"season={season} episode={episode} file={file_index}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# File specific tokens
|
|
|
|
# Assemble target filename tokens
|
|
|
|
targetFilenameTokens = []
|
|
|
|
targetFilenameTokens = []
|
|
|
|
targetFilenameExtension = DEFAULT_FILE_EXTENSION
|
|
|
|
targetFilenameExtension = DEFAULT_FILE_EXTENSION
|
|
|
|
|
|
|
|
|
|
|
@ -608,15 +612,17 @@ def convert(ctx,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
try:
|
|
|
|
streamDescriptor = getStreamDescriptor(sourcePath)
|
|
|
|
sourceStreamDescriptor = getStreamDescriptor(sourcePath)
|
|
|
|
|
|
|
|
targetStreamDescriptor = sourceStreamDescriptor.copy()
|
|
|
|
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
except Exception:
|
|
|
|
click.echo(f"File with path {sourcePath} does not contain any audiovisual data, skipping ...")
|
|
|
|
click.echo(f"File with path {sourcePath} does not contain any audiovisual data, skipping ...")
|
|
|
|
continue
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
for aStream in streamDescriptor[STREAM_TYPE_AUDIO]:
|
|
|
|
for aStream in sourceStreamDescriptor[STREAM_TYPE_AUDIO]:
|
|
|
|
click.echo(f"audio stream lang={aStream['language']}")
|
|
|
|
click.echo(f"audio stream lang={aStream['language']}")
|
|
|
|
|
|
|
|
|
|
|
|
for sStream in streamDescriptor[STREAM_TYPE_SUBTITLE]:
|
|
|
|
for sStream in sourceStreamDescriptor[STREAM_TYPE_SUBTITLE]:
|
|
|
|
click.echo(f"subtitle stream lang={sStream['language']}")
|
|
|
|
click.echo(f"subtitle stream lang={sStream['language']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -639,11 +645,10 @@ def convert(ctx,
|
|
|
|
if streamIndex <= len(subtitleTitles) -1:
|
|
|
|
if streamIndex <= len(subtitleTitles) -1:
|
|
|
|
mSubtitles[streamIndex]['title'] = subtitleTitles[streamIndex]
|
|
|
|
mSubtitles[streamIndex]['title'] = subtitleTitles[streamIndex]
|
|
|
|
|
|
|
|
|
|
|
|
if default_subtitle == -1 or no_jellyfin_tweaks:
|
|
|
|
if default_subtitle != -1 and jellyfin:
|
|
|
|
matchingSubtitles = mSubtitles
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
matchingSubtitles = getReorderedSubstreams(mSubtitles, default_subtitle)
|
|
|
|
matchingSubtitles = getReorderedSubstreams(mSubtitles, default_subtitle)
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
matchingSubtitles = mSubtitles
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -660,8 +665,8 @@ def convert(ctx,
|
|
|
|
audioTokens = []
|
|
|
|
audioTokens = []
|
|
|
|
|
|
|
|
|
|
|
|
# Source stream descriptors
|
|
|
|
# Source stream descriptors
|
|
|
|
audioStreams = streamDescriptor[STREAM_TYPE_AUDIO]
|
|
|
|
audioStreams = sourceStreamDescriptor[STREAM_TYPE_AUDIO]
|
|
|
|
subtitleStreams = streamDescriptor[STREAM_TYPE_SUBTITLE]
|
|
|
|
subtitleStreams = sourceStreamDescriptor[STREAM_TYPE_SUBTITLE]
|
|
|
|
|
|
|
|
|
|
|
|
# Set language and title in source stream descriptors if given per command line option
|
|
|
|
# Set language and title in source stream descriptors if given per command line option
|
|
|
|
for streamIndex in range(len(audioStreams)):
|
|
|
|
for streamIndex in range(len(audioStreams)):
|
|
|
@ -689,10 +694,7 @@ def convert(ctx,
|
|
|
|
for streamIndex in range(len(audioStreams)):
|
|
|
|
for streamIndex in range(len(audioStreams)):
|
|
|
|
audioStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == default_audio else 0
|
|
|
|
audioStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == default_audio else 0
|
|
|
|
|
|
|
|
|
|
|
|
if no_jellyfin_tweaks:
|
|
|
|
sourceAudioStreams = getReorderedSubstreams(audioStreams, default_audio) if jellyfin else audioStreams
|
|
|
|
sourceAudioStreams = audioStreams
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
sourceAudioStreams = getReorderedSubstreams(audioStreams, default_audio)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dispositionTokens += generateDispositionTokens(sourceAudioStreams)
|
|
|
|
dispositionTokens += generateDispositionTokens(sourceAudioStreams)
|
|
|
|
|
|
|
|
|
|
|
@ -708,10 +710,7 @@ def convert(ctx,
|
|
|
|
for streamIndex in range(len(subtitleStreams)):
|
|
|
|
for streamIndex in range(len(subtitleStreams)):
|
|
|
|
subtitleStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == default_subtitle else 0
|
|
|
|
subtitleStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == default_subtitle else 0
|
|
|
|
|
|
|
|
|
|
|
|
if no_jellyfin_tweaks:
|
|
|
|
sourceSubtitleStreams = getReorderedSubstreams(subtitleStreams, default_subtitle) if jellyfin else subtitleStreams
|
|
|
|
sourceSubtitleStreams = subtitleStreams
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
sourceSubtitleStreams = getReorderedSubstreams(subtitleStreams, default_subtitle)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dispositionTokens += generateDispositionTokens(sourceSubtitleStreams)
|
|
|
|
dispositionTokens += generateDispositionTokens(sourceSubtitleStreams)
|
|
|
|
|
|
|
|
|
|
|
@ -735,11 +734,18 @@ def convert(ctx,
|
|
|
|
# Create mapping and ffmpeg options for subtitle streams
|
|
|
|
# Create mapping and ffmpeg options for subtitle streams
|
|
|
|
subtitleMetadataTokens = []
|
|
|
|
subtitleMetadataTokens = []
|
|
|
|
if context['import_subtitles']:
|
|
|
|
if context['import_subtitles']:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
numMatchingSubtitles = len(matchingSubtitles)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if jellyfin and default_subtitle != -1:
|
|
|
|
|
|
|
|
subtitleSequence = getModifiedStreamOrder(numMatchingSubtitles, default_subtitle)
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
subtitleSequence = range(numMatchingSubtitles)
|
|
|
|
|
|
|
|
|
|
|
|
for fileIndex in range(len(matchingSubtitles)):
|
|
|
|
for fileIndex in range(numMatchingSubtitles):
|
|
|
|
|
|
|
|
|
|
|
|
# Create mapping for subtitle streams when imported from files
|
|
|
|
# Create mapping for subtitle streams when imported from files
|
|
|
|
mappingTokens += ['-map', f"{fileIndex+1}:s:0"]
|
|
|
|
mappingTokens += ['-map', f"{subtitleSequence[fileIndex]+1}:s:0"]
|
|
|
|
|
|
|
|
|
|
|
|
msg = matchingSubtitles[fileIndex]
|
|
|
|
msg = matchingSubtitles[fileIndex]
|
|
|
|
subtitleMetadataTokens += [f"-metadata:s:s:{fileIndex}", f"language={msg['language']}"]
|
|
|
|
subtitleMetadataTokens += [f"-metadata:s:s:{fileIndex}", f"language={msg['language']}"]
|
|
|
@ -789,7 +795,7 @@ def convert(ctx,
|
|
|
|
+ generateAV1Tokens(q, preset) + audioTokens)
|
|
|
|
+ generateAV1Tokens(q, preset) + audioTokens)
|
|
|
|
|
|
|
|
|
|
|
|
if clear_metadata:
|
|
|
|
if clear_metadata:
|
|
|
|
commandSequence += generateClearTokens(streamDescriptor)
|
|
|
|
commandSequence += generateClearTokens(sourceStreamDescriptor)
|
|
|
|
|
|
|
|
|
|
|
|
if context['perform_crop']:
|
|
|
|
if context['perform_crop']:
|
|
|
|
commandSequence += generateCropTokens(context['crop_start'], context['crop_length'])
|
|
|
|
commandSequence += generateCropTokens(context['crop_start'], context['crop_length'])
|
|
|
@ -833,7 +839,7 @@ def convert(ctx,
|
|
|
|
commandSequence2 += generateVP9Pass2Tokens(q) + audioTokens
|
|
|
|
commandSequence2 += generateVP9Pass2Tokens(q) + audioTokens
|
|
|
|
|
|
|
|
|
|
|
|
if clear_metadata:
|
|
|
|
if clear_metadata:
|
|
|
|
commandSequence2 += generateClearTokens(streamDescriptor)
|
|
|
|
commandSequence2 += generateClearTokens(sourceStreamDescriptor)
|
|
|
|
|
|
|
|
|
|
|
|
if context['perform_crop']:
|
|
|
|
if context['perform_crop']:
|
|
|
|
commandSequence2 += generateCropTokens(context['crop_start'], context['crop_length'])
|
|
|
|
commandSequence2 += generateCropTokens(context['crop_start'], context['crop_length'])
|
|
|
|