@ -12,7 +12,6 @@ VERSION='0.1.0'
DEFAULT_VIDEO_ENCODER = ' vp9 '
DEFAULT_VIDEO_ENCODER = ' vp9 '
DEFAULT_QUALITY = 23
DEFAULT_QUALITY = 23
DEFAULT_AV1_PRESET = 5
DEFAULT_AV1_PRESET = 5
DEFAULT_FILE_FORMAT = ' webm '
DEFAULT_FILE_FORMAT = ' webm '
@ -230,10 +229,30 @@ def getStreamDescriptor(filename):
s [ ' layout ' ] = ' undefined '
s [ ' layout ' ] = ' undefined '
descriptor [ s [ ' codec_type ' ] ] . append ( s )
descriptor [ s [ ' codec_type ' ] ] . append ( s )
descriptor [ s [ ' codec_type ' ] ] [ - 1 ] [ ' src_sub_index ' ] = len ( descriptor [ s [ ' codec_type ' ] ] ) - 1
return descriptor
return descriptor
def getModifiedStreamOrder ( length , last ) :
""" This is jellyfin specific as the last stream in the order is set as default """
seq = list ( range ( length ) )
if last < 0 or last > length - 1 :
return seq
seq . pop ( last )
seq . append ( last )
return seq
def getReorderedSubstreams ( subDescriptor , last ) :
numSubStreams = len ( subDescriptor )
modifiedOrder = getModifiedStreamOrder ( numSubStreams , last )
reorderedDescriptor = [ ]
for streamIndex in range ( numSubStreams ) :
reorderedDescriptor . append ( subDescriptor [ modifiedOrder [ streamIndex ] ] )
return reorderedDescriptor
def generateAV1Tokens ( q , p ) :
def generateAV1Tokens ( q , p ) :
return [ ' -c:v:0 ' , ' libsvtav1 ' ,
return [ ' -c:v:0 ' , ' libsvtav1 ' ,
@ -321,6 +340,25 @@ def generateClearTokens(streams):
return clearTokens
return clearTokens
def generateDispositionTokens ( subDescriptor ) :
""" -disposition:s:X default+forced """
dispositionTokens = [ ]
for subStreamIndex in range ( len ( subDescriptor ) ) :
subStream = subDescriptor [ subStreamIndex ]
streamType = subStream [ ' codec_type ' ] [ 0 ] # v|a|s
dispositionFlags = { k for ( k , v ) in subStream [ ' disposition ' ] . items ( ) if v == 1 } if ' disposition ' in subStream . keys ( ) else set ( )
if dispositionFlags :
dispositionTokens + = [ f " -disposition: { streamType } : { subStreamIndex } " , ' + ' . join ( dispositionFlags ) ]
else :
dispositionTokens + = [ f " -disposition: { streamType } : { subStreamIndex } " , ' 0 ' ]
return dispositionTokens
@click.group ( )
@click.group ( )
@click.pass_context
@click.pass_context
def ffx ( ctx ) :
def ffx ( ctx ) :
@ -370,10 +408,10 @@ 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 ( ' -ds ' , ' --default-subtitle ' , type = int , 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-audio ' , type = int , help= ' Index of forced audio stream (including default audio stream tag) ' )
@click.option ( ' -fa ' , ' --forced-audio ' , type = int , default= - 1 , help= ' Index of forced audio stream (including default audio stream tag) ' )
@click.option ( ' -da ' , ' --default-audio ' , type = int , help= ' Index of default audio stream ' )
@click.option ( ' -da ' , ' --default-audio ' , type = int , default= - 1 , help= ' Index of default audio stream ' )
@click.option ( " --crop " , is_flag = False , flag_value = " default " , default = " none " )
@click.option ( " --crop " , is_flag = False , flag_value = " default " , default = " none " )
@ -384,10 +422,12 @@ 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 ( " --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_audio , default_audio , crop , output_directory , clear_metadata , denoise , dry_run) :
def convert ( ctx , paths , label , video_encoder , quality , preset , stereo_bitrate , ac3_bitrate , dts_bitrate , default_subtitle , forced_audio , 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 .
@ -483,6 +523,7 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
print ( f " season= { season } episode= { episode } file= { file_index } " )
print ( f " season= { season } episode= { episode } file= { file_index } " )
# File specific tokens
targetFilenameTokens = [ ]
targetFilenameTokens = [ ]
targetFilenameExtension = DEFAULT_FILE_EXTENSION
targetFilenameExtension = DEFAULT_FILE_EXTENSION
@ -522,22 +563,34 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
job_index + = 1
job_index + = 1
mappingVideoTokens = [ ' -map ' , ' v:0 ' ]
mappingVideoTokens = [ ' -map ' , ' v:0 ' ]
mappingTokens = mappingVideoTokens . copy ( )
mappingTokens = mappingVideoTokens . copy ( )
dispositionTokens = [ ]
audioTokens = [ ]
audioTokens = [ ]
audioIndex = 0
for audioStreamDescriptor in streamDescriptor [ STREAM_TYPE_AUDIO ] :
mappingTokens + = [ ' -map ' , f " a: { audioIndex } " ]
if default_audio == - 1 :
audioTokens + = generateAudioTokens ( context , audioIndex , audioStreamDescriptor [ ' layout ' ] )
sourceAudioStreams = streamDescriptor [ STREAM_TYPE_AUDIO ]
audioIndex + = 1
else :
sourceAudioStreams = getReorderedSubstreams ( streamDescriptor [ STREAM_TYPE_AUDIO ] , default_audio )
dispositionTokens + = generateDispositionTokens ( sourceAudioStreams )
if default_subtitle == - 1 :
sourceSubtitleStreams = streamDescriptor [ STREAM_TYPE_SUBTITLE ]
else :
sourceSubtitleStreams = getReorderedSubstreams ( streamDescriptor [ STREAM_TYPE_SUBTITLE ] , default_subtitle )
dispositionTokens + = generateDispositionTokens ( sourceSubtitleStreams )
subtitleIndex = 0
for audioStream in sourceAudioStreams :
for subtitleStreamDescriptor in streamDescriptor [ STREAM_TYPE_SUBTITLE ] :
mappingTokens + = [ ' -map ' , f " a: { audioStream [ ' src_sub_index ' ] } " ]
mappingTokens + = [ ' -map ' , f " s: { subtitleIndex } " ]
audioTokens + = generateAudioTokens ( context , audioStream [ ' src_sub_index ' ] , audioStream [ ' layout ' ] )
subtitleIndex + = 1
for subtitleStream in sourceSubtitleStreams :
mappingTokens + = [ ' -map ' , f " s: { subtitleStream [ ' src_sub_index ' ] } " ]
# Job specific tokens
targetFilenameJobTokens = targetFilenameTokens . copy ( )
targetFilenameJobTokens = targetFilenameTokens . copy ( )
if len ( q_list ) > 1 :
if len ( q_list ) > 1 :
@ -552,10 +605,13 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
click . echo ( f " target filename: { targetFilename } " )
click . echo ( f " target filename: { targetFilename } " )
if video_encoder == ' av1 ' :
if video_encoder == ' av1 ' :
commandSequence = commandTokens + mappingTokens + audioTokens + generateAV1Tokens ( q , preset ) + audioTokens
commandSequence = ( commandTokens
+ mappingTokens
+ dispositionTokens
+ audioTokens
+ generateAV1Tokens ( q , preset ) + audioTokens )
if clear_metadata :
if clear_metadata :
commandSequence + = generateClearTokens ( streamDescriptor )
commandSequence + = generateClearTokens ( streamDescriptor )
@ -589,7 +645,9 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
executeProcess ( commandSequence1 )
executeProcess ( commandSequence1 )
commandSequence2 = commandTokens + mappingTokens
commandSequence2 = ( commandTokens
+ mappingTokens
+ dispositionTokens )
if denoise :
if denoise :
commandSequence2 + = generateDenoiseTokens ( )
commandSequence2 + = generateDenoiseTokens ( )