@ -37,7 +37,7 @@ MKVMERGE_METADATA_KEYS = ['BPS',
FILE_EXTENSIONS = [ ' mkv ' , ' mp4 ' , ' avi ' , ' flv ' , ' webm ' ]
COMMAND_TOKENS = [ ' ffmpeg ' , ' -y ' , ' -i ' ]
COMMAND_TOKENS = [ ' ffmpeg ' , ' -y ' ]
NULL_TOKENS = [ ' -f ' , ' null ' , ' /dev/null ' ]
STREAM_TYPE_VIDEO = ' video '
@ -51,6 +51,9 @@ STREAM_LAYOUT_6CH = '6ch'
SEASON_EPISODE_INDICATOR_MATCH = ' [sS]([0-9]+)[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 ) :
@ -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 ( ' -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 ( ' -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 ' )
@ -427,7 +439,30 @@ def streams(filename):
@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
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 " 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 ' )
if context [ ' perform_crop ' ] :
@ -481,10 +551,6 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
job_index = 0
se_match = re . compile ( SEASON_EPISODE_INDICATOR_MATCH )
e_match = re . compile ( EPISODE_INDICATOR_MATCH )
for sourcePath in existingSourcePaths :
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 ' ] } " )
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 :
click . echo ( f " \n Running job { job_index } file= { sourcePath } q= { q } " )
job_index + = 1
mappingVideoTokens = [ ' -map ' , ' v:0 ' ]
mappingVideoTokens = [ ' -map ' , ' 0: v:0' ]
mappingTokens = mappingVideoTokens . copy ( )
dispositionTokens = [ ]
audioTokens = [ ]
# Source stream descriptors
audioStreams = streamDescriptor [ STREAM_TYPE_AUDIO ]
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 :
sourceAudioStreams = audioStreams
else :
for streamIndex in range ( len ( audioStreams ) ) :
audioStreams [ streamIndex ] [ ' disposition ' ] [ ' default ' ] = 1 if streamIndex == default_audio else 0
sourceAudioStreams = getReorderedSubstreams ( audioStreams , default_audio )
dispositionTokens + = generateDispositionTokens ( sourceAudioStreams )
# Set forced tag in subtitle descriptor if given per command line option
if forced_subtitle != - 1 :
for streamIndex in range ( len ( subtitleStreams ) ) :
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 :
sourceSubtitleStreams = subtitleStreams
else :
for streamIndex in range ( len ( subtitleStreams ) ) :
subtitleStreams [ streamIndex ] [ ' disposition ' ] [ ' default ' ] = 1 if streamIndex == default_subtitle else 0
sourceSubtitleStreams = getReorderedSubstreams ( subtitleStreams , default_subtitle )
dispositionTokens + = generateDispositionTokens ( sourceSubtitleStreams )
for audioStream in sourceAudioStreams :
mappingTokens + = [ ' -map ' , f " a: { audioStream [ ' src_sub_index ' ] } " ]
audioMetadataTokens = [ ]
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 ' ] )
for subtitleStream in sourceSubtitleStreams :
mappingTokens + = [ ' -map ' , f " s: { subtitleStream [ ' src_sub_index ' ] } " ]
if ' tags ' in audioStream . keys ( ) :
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
@ -619,8 +767,11 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
if video_encoder == ' av1 ' :
commandSequence = ( commandTokens
+ subtitleFileTokens
+ mappingTokens
+ dispositionTokens
+ audioMetadataTokens
+ subtitleMetadataTokens
+ audioTokens
+ generateAV1Tokens ( q , preset ) + audioTokens )
@ -657,7 +808,10 @@ def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, a
commandSequence2 = ( commandTokens
+ subtitleFileTokens
+ mappingTokens
+ audioMetadataTokens
+ subtitleMetadataTokens
+ dispositionTokens )
if denoise :