@ -1,12 +1,11 @@
#! /usr/bin/python3
#! /usr/bin/python3
import os , sys, subprocess , json , click , time , re
import os , click, time , logging
from ffx . file_properties import FileProperties
from ffx . file_properties import FileProperties
from ffx . ffx_app import FfxApp
from ffx . ffx_app import FfxApp
from ffx . ffx_controller import FfxController
from ffx . ffx_controller import FfxController
from ffx . show_controller import ShowController
from ffx . tmdb_controller import TmdbController
from ffx . tmdb_controller import TmdbController
from ffx . database import databaseContext
from ffx . database import databaseContext
@ -32,11 +31,47 @@ VERSION='0.1.3'
@click.group ( )
@click.group ( )
@click.pass_context
@click.pass_context
@click.option ( ' --database-file ' , type = str , default = ' ' , help = ' Path to database file ' )
@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 """
""" FFX """
ctx . obj = { }
ctx . obj = { }
ctx . obj [ ' database ' ] = databaseContext ( databasePath = database_file )
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
# 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] " )
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 ( )
@ffx.command ( )
@click.pass_context
@click.pass_context
@click.argument ( ' filename ' , nargs = 1 )
@click.argument ( ' filename ' , nargs = 1 )
@ -107,15 +140,14 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
@click.option ( ' -l ' , ' --label ' , type = str , default = ' ' , help = ' Label to be used as filename prefix ' )
@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 ( " -o " , " --output-directory " , type = str , default = ' ' )
@click.option ( " -s " , " --subtitles-only " , is_flag = True , default = False )
@click.option ( " -s " , " --subtitles-only " , is_flag = True , default = False )
@click.option ( " --dry-run " , is_flag = True , default = False )
def unmux ( ctx ,
def unmux ( ctx ,
paths ,
paths ,
label ,
label ,
output_directory ,
output_directory ,
subtitles_only ,
subtitles_only ) :
dry_run ) :
existingSourcePaths = [ p for p in paths if os . path . isfile ( p ) ]
existingSourcePaths = [ p for p in paths if os . path . isfile ( p ) ]
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n Unmuxing { len ( existingSourcePaths ) } files " )
click . echo ( f " \n Unmuxing { len ( existingSourcePaths ) } files " )
for sourcePath in existingSourcePaths :
for sourcePath in existingSourcePaths :
@ -134,9 +166,11 @@ def unmux(ctx,
targetIndicator = f " _S { season } E { episode } " if label and season != - 1 and episode != - 1 else ' '
targetIndicator = f " _S { season } E { episode } " if label and season != - 1 and episode != - 1 else ' '
if label and not targetIndicator :
if label and not targetIndicator :
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Skipping file { fp . getFilename ( ) } : Label set but no indicator recognized " )
click . echo ( f " Skipping file { fp . getFilename ( ) } : Label set but no indicator recognized " )
continue
continue
else :
else :
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n Unmuxing file { fp . getFilename ( ) } \n " )
click . echo ( f " \n Unmuxing file { fp . getFilename ( ) } \n " )
for trackDescriptor in sourceMediaDescriptor . getAllTrackDescriptors ( ) :
for trackDescriptor in sourceMediaDescriptor . getAllTrackDescriptors ( ) :
@ -149,14 +183,18 @@ def unmux(ctx,
unmuxSequence = getUnmuxSequence ( trackDescriptor , sourcePath , targetPrefix , targetDirectory = output_directory )
unmuxSequence = getUnmuxSequence ( trackDescriptor , sourcePath , targetPrefix , targetDirectory = output_directory )
if unmuxSequence :
if unmuxSequence :
if not dry_run :
if not ctx . obj [ ' dry_run ' ] :
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Executing unmuxing sequence: { ' ' . join ( unmuxSequence ) } " )
click . echo ( f " Executing unmuxing sequence: { ' ' . join ( unmuxSequence ) } " )
out , err , rc = executeProcess ( unmuxSequence )
out , err , rc = executeProcess ( unmuxSequence )
if rc :
if rc :
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Unmuxing of stream { trackDescriptor . getIndex ( ) } failed with error ( { rc } ) { err } " )
click . echo ( f " Unmuxing of stream { trackDescriptor . getIndex ( ) } failed with error ( { rc } ) { err } " )
else :
else :
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Skipping stream with unknown codec { trackDescriptor . getCodec ( ) } " )
click . echo ( f " Skipping stream with unknown codec { trackDescriptor . getCodec ( ) } " )
except Exception as ex :
except Exception as ex :
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Skipping File { sourcePath } ( { ex } ) " )
click . echo ( f " Skipping File { sourcePath } ( { ex } ) " )
@ -215,9 +253,6 @@ def shows(ctx):
@click.option ( " -j " , " --no-jellyfin " , is_flag = True , default = False )
@click.option ( " -j " , " --no-jellyfin " , is_flag = True , default = False )
@click.option ( " -np " , " --no-pattern " , 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 ,
def convert ( ctx ,
paths ,
paths ,
label ,
label ,
@ -246,8 +281,7 @@ def convert(ctx,
denoise ,
denoise ,
no_tmdb ,
no_tmdb ,
no_jellyfin ,
no_jellyfin ,
no_pattern ,
no_pattern ) :
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 .
@ -259,8 +293,6 @@ def convert(ctx,
context = ctx . obj
context = ctx . obj
context [ ' dry_run ' ] = dry_run
context [ ' video_encoder ' ] = VideoEncoder . fromLabel ( video_encoder )
context [ ' video_encoder ' ] = VideoEncoder . fromLabel ( video_encoder )
context [ ' use_jellyfin ' ] = not no_jellyfin
context [ ' use_jellyfin ' ] = not no_jellyfin
@ -277,6 +309,7 @@ def convert(ctx,
qualityTokens = quality . split ( ' , ' )
qualityTokens = quality . split ( ' , ' )
q_list = [ q for q in qualityTokens if q . isnumeric ( ) ]
q_list = [ q for q in qualityTokens if q . isnumeric ( ) ]
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Qualities: { q_list } " )
click . echo ( f " Qualities: { q_list } " )
context [ ' bitrates ' ] = { }
context [ ' bitrates ' ] = { }
@ -284,6 +317,7 @@ def convert(ctx,
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 "
context [ ' bitrates ' ] [ ' dts ' ] = str ( dts_bitrate ) if str ( dts_bitrate ) . endswith ( ' k ' ) else f " { dts_bitrate } k "
context [ ' bitrates ' ] [ ' dts ' ] = str ( dts_bitrate ) if str ( dts_bitrate ) . endswith ( ' k ' ) else f " { dts_bitrate } k "
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Stereo bitrate: { context [ ' bitrates ' ] [ ' stereo ' ] } " )
click . echo ( f " Stereo bitrate: { context [ ' bitrates ' ] [ ' stereo ' ] } " )
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 ' ] } " )
@ -296,12 +330,14 @@ def convert(ctx,
if cTokens and len ( cTokens ) == 2 :
if cTokens and len ( cTokens ) == 2 :
context [ ' crop_start ' ] = int ( cTokens [ 0 ] )
context [ ' crop_start ' ] = int ( cTokens [ 0 ] )
context [ ' crop_length ' ] = int ( cTokens [ 1 ] )
context [ ' crop_length ' ] = int ( cTokens [ 1 ] )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Crop start= { context [ ' crop_start ' ] } length= { context [ ' crop_length ' ] } " )
click . echo ( f " Crop start= { context [ ' crop_start ' ] } length= { context [ ' crop_length ' ] } " )
tc = TmdbController ( ) if context [ ' use_tmdb ' ] else None
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 ]
existingSourcePaths = [ p for p in paths if os . path . isfile ( p ) and p . split ( ' . ' ) [ - 1 ] in FfxController . INPUT_FILE_EXTENSIONS ]
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n Running { len ( existingSourcePaths ) * len ( q_list ) } jobs " )
click . echo ( f " \n Running { len ( existingSourcePaths ) * len ( q_list ) } jobs " )
jobIndex = 0
jobIndex = 0
@ -315,6 +351,7 @@ def convert(ctx,
sourceFileBasename = ' . ' . join ( sourcePathTokens [ : - 1 ] )
sourceFileBasename = ' . ' . join ( sourcePathTokens [ : - 1 ] )
sourceFilenameExtension = sourcePathTokens [ - 1 ]
sourceFilenameExtension = sourcePathTokens [ - 1 ]
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n Processing file { sourcePath } " )
click . echo ( f " \n Processing file { sourcePath } " )
@ -324,6 +361,7 @@ def convert(ctx,
#HINT: This is None if the filename did not match anything in database
#HINT: This is None if the filename did not match anything in database
currentPattern = mediaFileProperties . getPattern ( ) if context [ ' use_pattern ' ] else None
currentPattern = mediaFileProperties . getPattern ( ) if context [ ' use_pattern ' ] else None
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Pattern matching: { ' No ' if currentPattern is None else ' Yes ' } " )
click . echo ( f " Pattern matching: { ' No ' if currentPattern is None else ' Yes ' } " )
# fileBasename = ''
# fileBasename = ''
@ -385,9 +423,9 @@ def convert(ctx,
# Case pattern matching
# Case pattern matching
targetMediaDescriptor = currentPattern . getMediaDescriptor ( )
targetMediaDescriptor = currentPattern . getMediaDescriptor ( ctx . obj )
currentShowDescriptor = currentPattern . getShowDescriptor ( )
currentShowDescriptor = currentPattern . getShowDescriptor ( ctx . obj )
if context [ ' use_tmdb ' ] :
if context [ ' use_tmdb ' ] :
@ -422,6 +460,7 @@ 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()]}")
# 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
# raise click.Abort
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Input mapping tokens (2nd pass): { targetMediaDescriptor . getInputMappingTokens ( ) } " )
click . echo ( f " Input mapping tokens (2nd pass): { targetMediaDescriptor . getInputMappingTokens ( ) } " )
fc = FfxController ( context , targetMediaDescriptor , sourceMediaDescriptor )
fc = FfxController ( context , targetMediaDescriptor , sourceMediaDescriptor )
@ -435,13 +474,15 @@ def convert(ctx,
# audioTokens = fc.generateAudioEncodingTokens()
# audioTokens = fc.generateAudioEncodingTokens()
# click.echo(f"Audio Tokens: {audioTokens}")
# click.echo(f"Audio Tokens: {audioTokens}")
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " Season= { mediaFileProperties . getSeason ( ) } Episode= { mediaFileProperties . getEpisode ( ) } " )
click . echo ( f " Season= { mediaFileProperties . getSeason ( ) } Episode= { mediaFileProperties . getEpisode ( ) } " )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " fileBasename= { sourceFileBasename } " )
click . echo ( f " fileBasename= { sourceFileBasename } " )
for q in q_list :
for q in q_list :
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n Running job { jobIndex } file= { sourcePath } q= { q } " )
click . echo ( f " \n Running job { jobIndex } file= { sourcePath } q= { q } " )
jobIndex + = 1
jobIndex + = 1
@ -464,6 +505,7 @@ def convert(ctx,
#TODO: click.confirm('Warning! This file is not compliant to the defined source schema! Do you want to continue?', abort=True)
#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 ( )
endTime = time . perf_counter ( )
if ctx . obj [ ' verbosity ' ] > 0 :
click . echo ( f " \n DONE \n Time elapsed { endTime - startTime } " )
click . echo ( f " \n DONE \n Time elapsed { endTime - startTime } " )