@ -1,12 +1,11 @@
#! /usr/bin/python3
import os , sys, subprocess , json , click , time , re
import os , click, time , logging
from ffx . file_properties import FileProperties
from ffx . ffx_app import FfxApp
from ffx . ffx_controller import FfxController
from ffx . show_controller import ShowController
from ffx . tmdb_controller import TmdbController
from ffx . database import databaseContext
@ -32,11 +31,47 @@ VERSION='0.1.3'
@click.group ( )
@click.pass_context
@click.option ( ' --database-file ' , type = str , default = ' ' , help = ' Path to database file ' )
def ffx ( ctx , database_file ) :
@click.option ( ' -v ' , ' --verbose ' , type = int , default = 0 , help = ' Set verbosity of output ' )
@click.option ( " --dry-run " , is_flag = True , default = False )
def ffx ( ctx , database_file , verbose , dry_run ) :
""" FFX """
ctx . obj = { }
ctx . obj [ ' database ' ] = databaseContext ( databasePath = database_file )
ctx . obj [ ' dry_run ' ] = dry_run
ctx . obj [ ' verbosity ' ] = verbose
# Critical 50
# Error 40
# Warning 30
# Info 20
# Debug 10
fileLogVerbosity = max ( 40 - verbose * 10 , 10 )
consoleLogVerbosity = max ( 20 - verbose * 10 , 10 )
homeDir = os . path . expanduser ( " ~ " )
ffxLogDir = os . path . join ( homeDir , ' .local ' , ' var ' , ' log ' )
if not os . path . exists ( ffxLogDir ) :
os . makedirs ( ffxLogDir )
ffxLogFilePath = os . path . join ( ffxLogDir , ' ffx.log ' )
ctx . obj [ ' logger ' ] = logging . getLogger ( ' FFX ' )
ctx . obj [ ' logger ' ] . setLevel ( logging . DEBUG )
ffxFileHandler = logging . FileHandler ( ffxLogFilePath )
ffxFileHandler . setLevel ( fileLogVerbosity )
ffxConsoleHandler = logging . StreamHandler ( )
ffxConsoleHandler . setLevel ( consoleLogVerbosity )
fileFormatter = logging . Formatter (
' %(asctime)s - %(name)s - %(levelname)s - %(message)s ' )
ffxFileHandler . setFormatter ( fileFormatter )
consoleFormatter = logging . Formatter (
' %(message)s ' )
ffxConsoleHandler . setFormatter ( consoleFormatter )
ctx . obj [ ' logger ' ] . addHandler ( ffxConsoleHandler )
ctx . obj [ ' logger ' ] . addHandler ( ffxFileHandler )
# Define a subcommand
@ -52,8 +87,6 @@ def help():
click . echo ( f " Usage: ffx [input file] [output file] [vp9|av1] [q=[nn[,nn,...]]] [p=nn] [a=nnn[k]] [ac3=nnn[k]] [dts=nnn[k]] [crop] " )
@ffx.command ( )
@click.pass_context
@click.argument ( ' filename ' , nargs = 1 )
@ -107,16 +140,15 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
@click.option ( ' -l ' , ' --label ' , type = str , default = ' ' , help = ' Label to be used as filename prefix ' )
@click.option ( " -o " , " --output-directory " , type = str , default = ' ' )
@click.option ( " -s " , " --subtitles-only " , is_flag = True , default = False )
@click.option ( " --dry-run " , is_flag = True , default = False )
def unmux ( ctx ,
paths ,
label ,
output_directory ,
subtitles_only ,
dry_run ) :
subtitles_only ) :
existingSourcePaths = [ p for p in paths if os . path . isfile ( p ) ]
click . echo ( f " \n Unmuxing { len ( existingSourcePaths ) } files " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n Unmuxing { len ( existingSourcePaths ) } files " )
for sourcePath in existingSourcePaths :
@ -134,10 +166,12 @@ def unmux(ctx,
targetIndicator = f " _S { season } E { episode } " if label and season != - 1 and episode != - 1 else ' '
if label and not targetIndicator :
click . echo ( f " Skipping file { fp . getFilename ( ) } : Label set but no indicator recognized " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Skipping file { fp . getFilename ( ) } : Label set but no indicator recognized " )
continue
else :
click . echo ( f " \n Unmuxing file { fp . getFilename ( ) } \n " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n Unmuxing file { fp . getFilename ( ) } \n " )
for trackDescriptor in sourceMediaDescriptor . getAllTrackDescriptors ( ) :
@ -149,15 +183,19 @@ def unmux(ctx,
unmuxSequence = getUnmuxSequence ( trackDescriptor , sourcePath , targetPrefix , targetDirectory = output_directory )
if unmuxSequence :
if not dry_run :
click . echo ( f " Executing unmuxing sequence: { ' ' . join ( unmuxSequence ) } " )
if not ctx . obj [ ' dry_run ' ] :
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Executing unmuxing sequence: { ' ' . join ( unmuxSequence ) } " )
out , err , rc = executeProcess ( unmuxSequence )
if rc :
click . echo ( f " Unmuxing of stream { trackDescriptor . getIndex ( ) } failed with error ( { rc } ) { err } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Unmuxing of stream { trackDescriptor . getIndex ( ) } failed with error ( { rc } ) { err } " )
else :
click . echo ( f " Skipping stream with unknown codec { trackDescriptor . getCodec ( ) } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Skipping stream with unknown codec { trackDescriptor . getCodec ( ) } " )
except Exception as ex :
click . echo ( f " Skipping File { sourcePath } ( { ex } ) " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Skipping File { sourcePath } ( { ex } ) " )
@ffx.command ( )
@ -215,9 +253,6 @@ def shows(ctx):
@click.option ( " -j " , " --no-jellyfin " , is_flag = True , default = False )
@click.option ( " -np " , " --no-pattern " , is_flag = True , default = False )
@click.option ( " --dry-run " , is_flag = True , default = False )
def convert ( ctx ,
paths ,
label ,
@ -246,8 +281,7 @@ def convert(ctx,
denoise ,
no_tmdb ,
no_jellyfin ,
no_pattern ,
dry_run ) :
no_pattern ) :
""" Batch conversion of audiovideo files in format suitable for web playback, e.g. jellyfin
Files found under PATHS will be converted according to parameters .
@ -258,8 +292,6 @@ def convert(ctx,
startTime = time . perf_counter ( )
context = ctx . obj
context [ ' dry_run ' ] = dry_run
context [ ' video_encoder ' ] = VideoEncoder . fromLabel ( video_encoder )
@ -277,16 +309,18 @@ def convert(ctx,
qualityTokens = quality . split ( ' , ' )
q_list = [ q for q in qualityTokens if q . isnumeric ( ) ]
click . echo ( f " Qualities: { q_list } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Qualities: { q_list } " )
context [ ' bitrates ' ] = { }
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 ' ] [ ' dts ' ] = str ( dts_bitrate ) if str ( dts_bitrate ) . endswith ( ' k ' ) else f " { dts_bitrate } k "
click . echo ( f " Stereo bitrate: { context [ ' bitrates ' ] [ ' stereo ' ] } " )
click . echo ( f " AC3 bitrate: { context [ ' bitrates ' ] [ ' ac3 ' ] } " )
click . echo ( f " DTS bitrate: { context [ ' bitrates ' ] [ ' dts ' ] } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Stereo bitrate: { context [ ' bitrates ' ] [ ' stereo ' ] } " )
click . echo ( f " AC3 bitrate: { context [ ' bitrates ' ] [ ' ac3 ' ] } " )
click . echo ( f " DTS bitrate: { context [ ' bitrates ' ] [ ' dts ' ] } " )
# Process crop parameters
@ -296,13 +330,15 @@ def convert(ctx,
if cTokens and len ( cTokens ) == 2 :
context [ ' crop_start ' ] = int ( cTokens [ 0 ] )
context [ ' crop_length ' ] = int ( cTokens [ 1 ] )
click . echo ( f " Crop start= { context [ ' crop_start ' ] } length= { context [ ' crop_length ' ] } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Crop start= { context [ ' crop_start ' ] } length= { context [ ' crop_length ' ] } " )
tc = TmdbController ( ) if context [ ' use_tmdb ' ] else None
existingSourcePaths = [ p for p in paths if os . path . isfile ( p ) and p . split ( ' . ' ) [ - 1 ] in FfxController . INPUT_FILE_EXTENSIONS ]
click . echo ( f " \n Running { len ( existingSourcePaths ) * len ( q_list ) } jobs " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n Running { len ( existingSourcePaths ) * len ( q_list ) } jobs " )
jobIndex = 0
for sourcePath in existingSourcePaths :
@ -315,7 +351,8 @@ def convert(ctx,
sourceFileBasename = ' . ' . join ( sourcePathTokens [ : - 1 ] )
sourceFilenameExtension = sourcePathTokens [ - 1 ]
click . echo ( f " \n Processing file { sourcePath } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n Processing file { sourcePath } " )
mediaFileProperties = FileProperties ( context , sourceFilename )
@ -324,7 +361,8 @@ def convert(ctx,
#HINT: This is None if the filename did not match anything in database
currentPattern = mediaFileProperties . getPattern ( ) if context [ ' use_pattern ' ] else None
click . echo ( f " Pattern matching: { ' No ' if currentPattern is None else ' Yes ' } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Pattern matching: { ' No ' if currentPattern is None else ' Yes ' } " )
# fileBasename = ''
@ -385,9 +423,9 @@ def convert(ctx,
# Case pattern matching
targetMediaDescriptor = currentPattern . getMediaDescriptor ( )
targetMediaDescriptor = currentPattern . getMediaDescriptor ( ctx . obj )
currentShowDescriptor = currentPattern . getShowDescriptor ( )
currentShowDescriptor = currentPattern . getShowDescriptor ( ctx . obj )
if context [ ' use_tmdb ' ] :
@ -422,7 +460,8 @@ def convert(ctx,
# click.echo(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
# raise click.Abort
click . echo ( f " Input mapping tokens (2nd pass): { targetMediaDescriptor . getInputMappingTokens ( ) } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Input mapping tokens (2nd pass): { targetMediaDescriptor . getInputMappingTokens ( ) } " )
fc = FfxController ( context , targetMediaDescriptor , sourceMediaDescriptor )
@ -435,14 +474,16 @@ def convert(ctx,
# audioTokens = fc.generateAudioEncodingTokens()
# click.echo(f"Audio Tokens: {audioTokens}")
click . echo ( f " Season= { mediaFileProperties . getSeason ( ) } Episode= { mediaFileProperties . getEpisode ( ) } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Season= { mediaFileProperties . getSeason ( ) } Episode= { mediaFileProperties . getEpisode ( ) } " )
click . echo ( f " fileBasename= { sourceFileBasename } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " fileBasename= { sourceFileBasename } " )
for q in q_list :
click . echo ( f " \n Running job { jobIndex } file= { sourcePath } q= { q } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n Running job { jobIndex } file= { sourcePath } q= { q } " )
jobIndex + = 1
extra = [ ' ffx ' ] if sourceFilenameExtension == FfxController . DEFAULT_FILE_EXTENSION else [ ]
@ -464,7 +505,8 @@ def convert(ctx,
#TODO: click.confirm('Warning! This file is not compliant to the defined source schema! Do you want to continue?', abort=True)
endTime = time . perf_counter ( )
click . echo ( f " \n DONE \n Time elapsed { endTime - startTime } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n DONE \n Time elapsed { endTime - startTime } " )
if __name__ == ' __main__ ' :