@ -37,7 +37,7 @@ MKVMERGE_METADATA_KEYS = ['BPS',
FILE_EXTENSIONS = [ ' mkv ' , ' mp4 ' , ' avi ' , ' flv ' , ' webm ' ]
FILE_EXTENSIONS = [ ' mkv ' , ' mp4 ' , ' avi ' , ' flv ' , ' webm ' ]
COMMAND_TOKENS = [ ' ffmpeg ' , ' -y ' , ' -i ' ]
COMMAND_TOKENS = [ ' ffmpeg ' , ' -y ' ]
NULL_TOKENS = [ ' -f ' , ' null ' , ' /dev/null ' ]
NULL_TOKENS = [ ' -f ' , ' null ' , ' /dev/null ' ]
STREAM_TYPE_VIDEO = ' video '
STREAM_TYPE_VIDEO = ' video '
@ -51,6 +51,9 @@ STREAM_LAYOUT_6CH = '6ch'
SEASON_EPISODE_INDICATOR_MATCH = ' [sS]([0-9]+)[eE]([0-9]+) '
SEASON_EPISODE_INDICATOR_MATCH = ' [sS]([0-9]+)[eE]([0-9]+) '
EPISODE_INDICATOR_MATCH = ' [eE]([0-9]+) '
EPISODE_INDICATOR_MATCH = ' [eE]([0-9]+) '
SEASON_EPISODE_STREAM_LANGUAGE_MATCH = ' [sS]([0-9]+)[eE]([0-9]+)_([0-9]+)_([a-z] {3} ) '
SUBTITLE_FILE_EXTENSION = ' vtt '
class DashboardScreen ( Screen ) :
class DashboardScreen ( Screen ) :
@ -408,8 +411,17 @@ def streams(filename):
@click.option ( ' -ac3 ' , ' --ac3-bitrate ' , type = int , default = DEFAULT_AC3_BANDWIDTH , help = f " Bitrate in kbit/s to be used to encode 5.1 audio streams (default: { DEFAULT_AC3_BANDWIDTH } ) " )
@click.option ( ' -ac3 ' , ' --ac3-bitrate ' , type = int , default = DEFAULT_AC3_BANDWIDTH , help = f " Bitrate in kbit/s to be used to encode 5.1 audio streams (default: { DEFAULT_AC3_BANDWIDTH } ) " )
@click.option ( ' -dts ' , ' --dts-bitrate ' , type = int , default = DEFAULT_DTS_BANDWIDTH , help = f " Bitrate in kbit/s to be used to encode 6.1 audio streams (default: { DEFAULT_DTS_BANDWIDTH } ) " )
@click.option ( ' -dts ' , ' --dts-bitrate ' , type = int , default = DEFAULT_DTS_BANDWIDTH , help = f " Bitrate in kbit/s to be used to encode 6.1 audio streams (default: { DEFAULT_DTS_BANDWIDTH } ) " )
@click.option ( ' -sd ' , ' --subtitle-directory ' , type = str , default = ' ' , help = ' Load subtitles from here ' )
@click.option ( ' -sl ' , ' --subtitle-label ' , type = str , default = ' ' , help = ' Subtitle filename prefix ' )
@click.option ( ' -ss ' , ' --subtitle-language ' , type = str , default = ' ' , help = ' Subtitle stream language(s) ' )
@click.option ( ' -st ' , ' --subtitle-title ' , type = str , default = ' ' , help = ' Subtitle stream title(s) ' )
@click.option ( ' -ds ' , ' --default-subtitle ' , type = int , default = - 1 , help = ' Index of default subtitle stream ' )
@click.option ( ' -ds ' , ' --default-subtitle ' , type = int , default = - 1 , help = ' Index of default subtitle stream ' )
@click.option ( ' -fa ' , ' --forced-subtitle ' , type = int , default = - 1 , help = ' Index of forced subtitle stream ' ) # (including default audio stream tag)
@click.option ( ' -fs ' , ' --forced-subtitle ' , type = int , default = - 1 , help = ' Index of forced subtitle stream ' ) # (including default audio stream tag)
@click.option ( ' -as ' , ' --audio-language ' , type = str , default = ' ' , help = ' Audio stream language(s) ' )
@click.option ( ' -at ' , ' --audio-title ' , type = str , default = ' ' , help = ' Audio stream title(s) ' )
@click.option ( ' -da ' , ' --default-audio ' , type = int , default = - 1 , help = ' Index of default audio stream ' )
@click.option ( ' -da ' , ' --default-audio ' , type = int , default = - 1 , help = ' Index of default audio stream ' )
@ -427,7 +439,30 @@ def streams(filename):
@click.option ( " --dry-run " , is_flag = True , default = False )
@click.option ( " --dry-run " , is_flag = True , default = False )
def convert ( ctx , paths , label , video_encoder , quality , preset , stereo_bitrate , ac3_bitrate , dts_bitrate , default_subtitle , forced_subtitle , default_audio , crop , output_directory , clear_metadata , denoise , no_jellyfin_tweaks , dry_run ) :
def convert ( ctx ,
paths ,
label ,
video_encoder ,
quality ,
preset ,
stereo_bitrate ,
ac3_bitrate ,
dts_bitrate ,
subtitle_directory ,
subtitle_label ,
subtitle_language ,
subtitle_title ,
default_subtitle ,
forced_subtitle ,
audio_language ,
audio_title ,
default_audio ,
crop ,
output_directory ,
clear_metadata ,
denoise ,
no_jellyfin_tweaks ,
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
Files found under PATHS will be converted according to parameters .
Files found under PATHS will be converted according to parameters .
@ -457,6 +492,41 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
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_label )
se_match = re . compile ( SEASON_EPISODE_INDICATOR_MATCH )
e_match = re . compile ( EPISODE_INDICATOR_MATCH )
sesl_match = re . compile ( SEASON_EPISODE_STREAM_LANGUAGE_MATCH )
availableSubtitles = [ ]
if context [ ' import_subtitles ' ] :
for subtitleFilename in os . listdir ( subtitle_directory ) :
if subtitleFilename . startswith ( subtitle_label ) 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 )
availableSubtitles . append ( subtitleFileDescriptor )
click . echo ( f " Found { len ( availableSubtitles ) } subtitles in files " )
subtitleLanguages = subtitle_language . split ( ' , ' ) if subtitle_language else [ ]
subtitleTitles = subtitle_title . split ( ' , ' ) if subtitle_title else [ ]
audioLanguages = audio_language . split ( ' , ' ) if audio_language else [ ]
audioTitles = audio_title . split ( ' , ' ) if audio_title else [ ]
# Process crop parameters
context [ ' perform_crop ' ] = ( crop != ' none ' )
context [ ' perform_crop ' ] = ( crop != ' none ' )
if context [ ' perform_crop ' ] :
if context [ ' perform_crop ' ] :
@ -481,10 +551,6 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
job_index = 0
job_index = 0
se_match = re . compile ( SEASON_EPISODE_INDICATOR_MATCH )
e_match = re . compile ( EPISODE_INDICATOR_MATCH )
for sourcePath in existingSourcePaths :
for sourcePath in existingSourcePaths :
sourceDirectory = os . path . dirname ( sourcePath )
sourceDirectory = os . path . dirname ( sourcePath )
@ -554,51 +620,133 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
click . echo ( f " subtitle stream lang= { sStream [ ' language ' ] } " )
click . echo ( f " subtitle stream lang= { sStream [ ' language ' ] } " )
commandTokens = COMMAND_TOKENS + [ ' -i ' , sourcePath ]
subtitleFileTokens = [ ]
matchingSubtitles = [ ]
if context [ ' import_subtitles ' ] :
subtitles = [ a for a in availableSubtitles if a [ ' season ' ] == season and a [ ' episode ' ] == episode ]
mSubtitles = sorted ( subtitles , key = lambda d : d [ ' stream ' ] )
for sfd in mSubtitles :
subtitleFileTokens + = [ ' -i ' , sfd [ ' path ' ] ]
for streamIndex in range ( len ( mSubtitles ) ) :
mSubtitles [ streamIndex ] [ ' forced ' ] = 1 if forced_subtitle != - 1 and streamIndex == forced_subtitle else 0
mSubtitles [ streamIndex ] [ ' default ' ] = 1 if default_subtitle != - 1 and streamIndex == default_subtitle else 0
if default_subtitle == - 1 :
matchingSubtitles = mSubtitles
else :
matchingSubtitles = getReorderedSubstreams ( mSubtitles , default_subtitle )
commandTokens = COMMAND_TOKENS + [ sourcePath ]
for q in q_list :
for q in q_list :
click . echo ( f " \n Running job { job_index } file= { sourcePath } q= { q } " )
click . echo ( f " \n Running job { job_index } file= { sourcePath } q= { q } " )
job_index + = 1
job_index + = 1
mappingVideoTokens = [ ' -map ' , ' v:0 ' ]
mappingVideoTokens = [ ' -map ' , ' 0: v:0' ]
mappingTokens = mappingVideoTokens . copy ( )
mappingTokens = mappingVideoTokens . copy ( )
dispositionTokens = [ ]
dispositionTokens = [ ]
audioTokens = [ ]
audioTokens = [ ]
# Source stream descriptors
audioStreams = streamDescriptor [ STREAM_TYPE_AUDIO ]
audioStreams = streamDescriptor [ STREAM_TYPE_AUDIO ]
subtitleStreams = streamDescriptor [ STREAM_TYPE_SUBTITLE ]
subtitleStreams = streamDescriptor [ STREAM_TYPE_SUBTITLE ]
# Set language and title in source stream descriptors if given per command line option
for streamIndex in range ( len ( audioStreams ) ) :
if ' tags ' not in audioStreams [ streamIndex ] . keys ( ) :
audioStreams [ streamIndex ] [ ' tags ' ] = { }
if streamIndex < = len ( audioLanguages ) - 1 :
audioStreams [ streamIndex ] [ ' tags ' ] [ ' language ' ] = audioLanguages [ streamIndex ]
if streamIndex < = len ( audioTitles ) - 1 :
audioStreams [ streamIndex ] [ ' tags ' ] [ ' title ' ] = audioTitles [ streamIndex ]
for streamIndex in range ( len ( subtitleStreams ) ) :
if ' tags ' not in subtitleStreams [ streamIndex ] . keys ( ) :
subtitleStreams [ streamIndex ] [ ' tags ' ] = { }
if streamIndex < = len ( subtitleLanguages ) - 1 :
subtitleStreams [ streamIndex ] [ ' tags ' ] [ ' language ' ] = subtitleLanguages [ streamIndex ]
if streamIndex < = len ( subtitleTitles ) - 1 :
subtitleStreams [ streamIndex ] [ ' tags ' ] [ ' title ' ] = subtitleTitles [ streamIndex ]
# Reorder audio stream descriptors and create disposition options if default is given per command line option
if default_audio == - 1 :
if default_audio == - 1 :
sourceAudioStreams = audioStreams
sourceAudioStreams = audioStreams
else :
else :
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
sourceAudioStreams = getReorderedSubstreams ( audioStreams , default_audio )
sourceAudioStreams = getReorderedSubstreams ( audioStreams , default_audio )
dispositionTokens + = generateDispositionTokens ( sourceAudioStreams )
dispositionTokens + = generateDispositionTokens ( sourceAudioStreams )
# Set forced tag in subtitle descriptor if given per command line option
if forced_subtitle != - 1 :
if forced_subtitle != - 1 :
for streamIndex in range ( len ( subtitleStreams ) ) :
for streamIndex in range ( len ( subtitleStreams ) ) :
subtitleStreams [ streamIndex ] [ ' disposition ' ] [ ' forced ' ] = 1 if streamIndex == forced_subtitle else 0
subtitleStreams [ streamIndex ] [ ' disposition ' ] [ ' forced ' ] = 1 if streamIndex == forced_subtitle else 0
# Reorder subtitle stream descriptors and create disposition options if default is given per command line option
if default_subtitle == - 1 :
if default_subtitle == - 1 :
sourceSubtitleStreams = subtitleStreams
sourceSubtitleStreams = subtitleStreams
else :
else :
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
sourceSubtitleStreams = getReorderedSubstreams ( subtitleStreams , default_subtitle )
sourceSubtitleStreams = getReorderedSubstreams ( subtitleStreams , default_subtitle )
dispositionTokens + = generateDispositionTokens ( sourceSubtitleStreams )
dispositionTokens + = generateDispositionTokens ( sourceSubtitleStreams )
for audioStream in sourceAudioStreams :
audioMetadataTokens = [ ]
mappingTokens + = [ ' -map ' , f " a: { audioStream [ ' src_sub_index ' ] } " ]
for audioStreamIndex in range ( len ( sourceAudioStreams ) ) :
audioStream = sourceAudioStreams [ audioStreamIndex ]
# Create mapping and ffmpeg options for audio streams
mappingTokens + = [ ' -map ' , f " 0:a: { audioStream [ ' src_sub_index ' ] } " ]
audioTokens + = generateAudioTokens ( context , audioStream [ ' src_sub_index ' ] , audioStream [ ' layout ' ] )
audioTokens + = generateAudioTokens ( context , audioStream [ ' src_sub_index ' ] , audioStream [ ' layout ' ] )
for subtitleStream in sourceSubtitleStreams :
if ' tags ' in audioStream . keys ( ) :
mappingTokens + = [ ' -map ' , f " s: { subtitleStream [ ' src_sub_index ' ] } " ]
if ' language ' in audioStream [ ' tags ' ] . keys ( ) :
audioMetadataTokens + = [ f " -metadata:s:a: { audioStreamIndex } " , f " language= { audioStream [ ' tags ' ] [ ' language ' ] } " ]
if ' title ' in audioStream [ ' tags ' ] . keys ( ) :
audioMetadataTokens + = [ f " -metadata:s:a: { audioStreamIndex } " , f " title= { audioStream [ ' tags ' ] [ ' title ' ] } " ]
# Create mapping and ffmpeg options for subtitle streams
subtitleMetadataTokens = [ ]
if context [ ' import_subtitles ' ] :
for fileIndex in range ( len ( matchingSubtitles ) ) :
# Create mapping for subtitle streams when imported from files
mappingTokens + = [ ' -map ' , f " { fileIndex + 1 } :s:0 " ]
msg = matchingSubtitles [ fileIndex ]
subtitleMetadataTokens + = [ f " -metadata:s:s: { fileIndex } " , f " language= { msg [ ' language ' ] } " ]
else :
for subtitleStreamIndex in range ( len ( sourceSubtitleStreams ) ) :
subtitleStream = sourceSubtitleStreams [ subtitleStreamIndex ]
# Create mapping for subtitle streams
mappingTokens + = [ ' -map ' , f " s: { subtitleStream [ ' src_sub_index ' ] } " ]
if ' tags ' in subtitleStream . keys ( ) :
if ' language ' in subtitleStream [ ' tags ' ] . keys ( ) :
subtitleMetadataTokens + = [ f " -metadata:s:s: { subtitleStreamIndex } " , f " language= { subtitleStream [ ' tags ' ] [ ' language ' ] } " ]
if ' title ' in subtitleStream [ ' tags ' ] . keys ( ) :
subtitleMetadataTokens + = [ f " -metadata:s:s: { subtitleStreamIndex } " , f " title= { subtitleStream [ ' tags ' ] [ ' title ' ] } " ]
# Job specific tokens
# Job specific tokens
@ -619,8 +767,11 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
if video_encoder == ' av1 ' :
if video_encoder == ' av1 ' :
commandSequence = ( commandTokens
commandSequence = ( commandTokens
+ subtitleFileTokens
+ mappingTokens
+ mappingTokens
+ dispositionTokens
+ dispositionTokens
+ audioMetadataTokens
+ subtitleMetadataTokens
+ audioTokens
+ audioTokens
+ generateAV1Tokens ( q , preset ) + audioTokens )
+ generateAV1Tokens ( q , preset ) + audioTokens )
@ -657,7 +808,10 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
commandSequence2 = ( commandTokens
commandSequence2 = ( commandTokens
+ subtitleFileTokens
+ mappingTokens
+ mappingTokens
+ audioMetadataTokens
+ subtitleMetadataTokens
+ dispositionTokens )
+ dispositionTokens )
if denoise :
if denoise :