You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ffx/bin/ffx.py

569 lines
22 KiB
Python

#! /usr/bin/python3
import os, sys, subprocess, json, click, time, re
from ffx.file_properties import FileProperties
from ffx.ffx_app import FfxApp
from ffx.ffx_controller import FfxController
from ffx.database import databaseContext
from ffx.track_type import TrackType
VERSION='0.1.0'
@click.group()
@click.pass_context
def ffx(ctx):
"""FFX"""
ctx.obj = {}
ctx.obj['database'] = databaseContext()
# Define a subcommand
@ffx.command()
def version():
click.echo(VERSION)
# Another subcommand
@ffx.command()
def help():
click.echo(f"ffx {VERSION}\n")
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)
def inspect(ctx, filename):
ctx.obj['command'] = 'inspect'
ctx.obj['arguments'] = {}
ctx.obj['arguments']['filename'] = filename
app = FfxApp(ctx.obj)
app.run()
# @ffx.command()
# @click.pass_context
#
# @click.argument('paths', nargs=-1)
# @click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
#
# @click.option('-sd', '--subtitle-directory', type=str, default='', help='Load subtitles from here')
# @click.option('-sp', '--subtitle-prefix', type=str, default='', help='Subtitle filename prefix')
#
# @click.option("-o", "--output-directory", type=str, default='')
#
# @click.option("--dry-run", is_flag=True, default=False)
#
#
# def unmux(ctx,
# label,
# paths,
# subtitle_directory,
# subtitle_prefix,
# output_directory,
# dry_run):
#
# existingSourcePaths = [p for p in paths if os.path.isfile(p)]
# click.echo(f"\nUnmuxing {len(existingSourcePaths)} files")
#
# for sourcePath in existingSourcePaths:
#
# sd = getStreamDescriptor(sourcePath)
#
# print(f"\nFile {sourcePath}\n")
#
# for v in sd['video']:
#
# if v['codec_name'] == 'h264':
#
# commandSequence = ['ffmpeg', '-i', sourcePath, '-map', '0:v:0', '-c', 'copy', '-f', 'h264']
# executeProcess()
#
# for a in sd['audio']:
# print(f"A: {a}\n")
# for s in sd['subtitle']:
# print(f"S: {s}\n")
@ffx.command()
@click.pass_context
def shows(ctx):
ctx.obj['command'] = 'shows'
app = FfxApp(ctx.obj)
app.run()
@ffx.command()
@click.pass_context
@click.argument('paths', nargs=-1)
@click.option("-t", "--tmdb", is_flag=True, default=False)
@click.option("-j", "--jellyfin", is_flag=True, default=False)
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
@click.option('-v', '--video-encoder', type=str, default=FfxController.DEFAULT_VIDEO_ENCODER, help=f"Target video encoder (vp9 or av1) default: {FfxController.DEFAULT_VIDEO_ENCODER}")
@click.option('-q', '--quality', type=str, default=FfxController.DEFAULT_QUALITY, help=f"Quality settings to be used with VP9 encoder (default: {FfxController.DEFAULT_QUALITY})")
@click.option('-p', '--preset', type=str, default=FfxController.DEFAULT_AV1_PRESET, help=f"Quality preset to be used with AV1 encoder (default: {FfxController.DEFAULT_AV1_PRESET})")
@click.option('-a', '--stereo-bitrate', type=int, default=FfxController.DEFAULT_STEREO_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode stereo audio streams (default: {FfxController.DEFAULT_STEREO_BANDWIDTH})")
@click.option('-ac3', '--ac3-bitrate', type=int, default=FfxController.DEFAULT_AC3_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 5.1 audio streams (default: {FfxController.DEFAULT_AC3_BANDWIDTH})")
@click.option('-dts', '--dts-bitrate', type=int, default=FfxController.DEFAULT_DTS_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 6.1 audio streams (default: {FfxController.DEFAULT_DTS_BANDWIDTH})")
@click.option('-sd', '--subtitle-directory', type=str, default='', help='Load subtitles from here')
@click.option('-sp', '--subtitle-prefix', type=str, default='', help='Subtitle filename prefix')
@click.option('-ss', '--subtitle-language', type=str, multiple=True, help='Subtitle stream language(s)')
@click.option('-st', '--subtitle-title', type=str, multiple=True, help='Subtitle stream title(s)')
@click.option('-ds', '--default-subtitle', type=int, default=-1, help='Index of default subtitle stream')
@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, multiple=True, help='Audio stream language(s)')
@click.option('-at', '--audio-title', type=str, multiple=True, help='Audio stream title(s)')
@click.option('-da', '--default-audio', type=int, default=-1, help='Index of default audio stream')
@click.option('-da', '--forced-audio', type=int, default=-1, help='Index of forced audio stream')
@click.option("--crop", is_flag=False, flag_value="default", default="none")
@click.option("-o", "--output-directory", type=str, default='')
@click.option("-c", "--clear-metadata", is_flag=True, default=False)
@click.option("-d", "--denoise", is_flag=True, default=False)
@click.option("--dry-run", is_flag=True, default=False)
def convert(ctx,
paths,
tmdb,
jellyfin,
label,
video_encoder,
quality,
preset,
stereo_bitrate,
ac3_bitrate,
dts_bitrate,
subtitle_directory,
subtitle_prefix,
subtitle_language,
subtitle_title,
default_subtitle,
forced_subtitle,
audio_language,
audio_title,
default_audio,
forced_audio,
crop,
output_directory,
clear_metadata,
denoise,
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.
Filename extensions will be changed appropriately.
Suffices will we appended to filename in case of multiple created files
or if the filename has not changed."""
startTime = time.perf_counter()
context = ctx.obj
context['jellyfin'] = jellyfin
context['tmdb'] = tmdb
# click.echo(f"\nVideo encoder: {video_encoder}")
qualityTokens = quality.split(',')
q_list = [q for q in qualityTokens if q.isnumeric()]
# 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']}")
# Process crop parameters
context['perform_crop'] = (crop != 'none')
if context['perform_crop']:
cTokens = crop.split(',')
if cTokens and len(cTokens) == 2:
cropStart = int(cTokens[0])
cropLength = int(cTokens[1])
cropTokens = FfxController.generateCropTokens(cropStart, cropLength)
else:
cropTokens = FfxController.generateCropTokens()
else:
cropTokens = []
click.echo(f"Crop tokens={cropTokens}")
# ## Conversion parameters
#
# # Parse subtitle files
# context['import_subtitles'] = (subtitle_directory and subtitle_prefix)
# availableFileSubtitleDescriptors = searchSubtitleFiles(subtitle_directory, subtitle_prefix) if context['import_subtitles'] else []
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in FfxController.INPUT_FILE_EXTENSIONS]
click.echo(f"\nRunning {len(existingSourcePaths) * len(q_list)} jobs")
for sourcePath in existingSourcePaths:
# Separate basedir, basename and extension for current source file
sourceDirectory = os.path.dirname(sourcePath)
sourceFilename = os.path.basename(sourcePath)
sourcePathTokens = sourceFilename.split('.')
sourceFileBasename = '.'.join(sourcePathTokens[:-1])
sourceFilenameExtension = sourcePathTokens[-1]
click.echo(f"\nProcessing file {sourcePath}")
mediaFileProperties = FileProperties(context, sourceFilename)
sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor()
#HINT: This is None if the filename did not match anything in database
currentPattern = mediaFileProperties.getPattern()
click.echo(f"Pattern matching: {'No' if currentPattern is None else 'Yes'}")
if currentPattern is None:
# Case no pattern matching
# Check for multiple default or forced dispositions if not set by user input or database requirements
#
# Query user for the correct sub indices, then configure flags in track descriptors associated with media descriptor accordingly.
# The correct tokens should then be created by
try:
sourceMediaDescriptor.getDefaultVideoTrack()
except ValueError:
defaultVideoTrackSubIndex = click.prompt("More than one default video stream detected! Please select stream", type=int)
sourceMediaDescriptor.setDefaultSubTrack(TrackType.VIDEO, defaultVideoTrackSubIndex)
try:
sourceMediaDescriptor.getForcedVideoTrack()
except ValueError:
forcedVideoTrackSubIndex = click.prompt("More than one forced video stream detected! Please select stream", type=int)
sourceMediaDescriptor.setForcedSubTrack(TrackType.VIDEO, forcedVideoTrackSubIndex)
try:
sourceMediaDescriptor.getDefaultAudioTrack()
except ValueError:
defaultAudioTrackSubIndex = click.prompt("More than one default audio stream detected! Please select stream", type=int)
sourceMediaDescriptor.setDefaultSubTrack(TrackType.AUDIO, defaultAudioTrackSubIndex)
try:
sourceMediaDescriptor.getForcedAudioTrack()
except ValueError:
forcedAudioTrackSubIndex = click.prompt("More than one forced audio stream detected! Please select stream", type=int)
sourceMediaDescriptor.setForcedSubTrack(TrackType.AUDIO, forcedAudioTrackSubIndex)
try:
sourceMediaDescriptor.getDefaultSubtitleTrack()
except ValueError:
defaultSubtitleTrackSubIndex = click.prompt("More than one default subtitle stream detected! Please select stream", type=int)
sourceMediaDescriptor.setDefaultSubTrack(TrackType.SUBTITLE, defaultSubtitleTrackSubIndex)
try:
sourceMediaDescriptor.getForcedSubtitleTrack()
except ValueError:
forcedSubtitleTrackSubIndex = click.prompt("More than one forced subtitle stream detected! Please select stream", type=int)
sourceMediaDescriptor.setForcedSubTrack(TrackType.SUBTITLE, forcedSubtitleTrackSubIndex)
fc = FfxController(context, sourceMediaDescriptor)
dispositionTokens = fc.generateDispositionTokens()
click.echo(f"Disposition Tokens: {dispositionTokens}")
else:
# Case pattern matching
targetMediaDescriptor = currentPattern.getMediaDescriptor() if currentPattern is not None else None
targetMediaDescriptor.setJellyfinOrder(context['jellyfin'])
click.echo(f"Input mapping tokens: {targetMediaDescriptor.getInputMappingTokens()}")
fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor)
mappingTokens = fc.generateMetadataTokens()
click.echo(f"Metadata Tokens: {mappingTokens}")
dispositionTokens = fc.generateDispositionTokens()
click.echo(f"Disposition Tokens: {dispositionTokens}")
audioTokens = fc.generateAudioEncodingTokens()
click.echo(f"Audio Tokens: {audioTokens}")
click.echo(f"Season={mediaFileProperties.getSeason()} Episode={mediaFileProperties.getEpisode()}")
# matchingFileSubtitleDescriptors = sorted([d for d in availableFileSubtitleDescriptors if d['season'] == season and d['episode'] == episode], key=lambda d: d['stream']) if availableFileSubtitleDescriptors else []
#
# print(f"season={season} episode={episode} file={file_index}")
#
#
# # Assemble target filename tokens
# targetFilenameTokens = []
# targetFilenameExtension = FfxController.DEFAULT_FILE_EXTENSION
#
# if label:
# targetFilenameTokens = [label]
#
# if season > -1 and episode > -1:
# targetFilenameTokens += [f"S{season:0{season_digits}d}E{episode:0{episode_digits}d}"]
# elif episode > -1:
# targetFilenameTokens += [f"E{episode:0{episode_digits}d}"]
# else:
# targetFilenameTokens += [f"{file_index:0{index_digits}d}"]
#
# else:
# targetFilenameTokens = [sourceFileBasename]
#
# ###
# ###
#
# # Load source stream descriptor
# try:
# ###
# sourceStreamDescriptor = getStreamDescriptor(sourcePath)
# ###
#
# except Exception:
# click.echo(f"File with path {sourcePath} does not contain any audiovisual data, skipping ...")
# continue
#
#
# ## ## ##
# targetStreamDescriptor = sourceStreamDescriptor.copy()
# ## ## ##
# commandTokens = COMMAND_TOKENS + ['-i', sourcePath]
#
#
# # matchingSubtitles = []
# # if context['import_subtitles']:
# #
#
#
# #
# # for streamIndex in range(len(mSubtitles)):
# # mSubtitles[streamIndex]['forced'] = 1 if forcedSubtitle != -1 and streamIndex == forcedSubtitle else 0
# # mSubtitles[streamIndex]['default'] = 1 if defaultSubtitle != -1 and streamIndex == defaultSubtitle else 0
# #
# # if streamIndex <= len(subtitleTitles) -1:
# # mSubtitles[streamIndex]['title'] = subtitleTitles[streamIndex]
# #
# # if defaultSubtitle != -1 and jellyfin:
# # matchingSubtitles = getReorderedSubstreams(mSubtitles, defaultSubtitle)
# # else:
# # matchingSubtitles = mSubtitles
#
#
#
# for q in q_list:
#
# click.echo(f"\nRunning job {job_index} file={sourcePath} q={q}")
# job_index += 1
#
#
# # # Reorder audio stream descriptors and create disposition options if default is given per command line option
# # if defaultAudio == -1:
# # sourceAudioStreams = audioStreams
# # else:
# # for streamIndex in range(len(audioStreams)):
# # audioStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == defaultAudio else 0
# #
# # sourceAudioStreams = getReorderedSubstreams(audioStreams, defaultAudio) if jellyfin else audioStreams
# #
# # dispositionTokens += generateDispositionTokens(sourceAudioStreams)
# #
# # # Set forced tag in subtitle descriptor if given per command line option
# # if forcedSubtitle != -1:
# # for streamIndex in range(len(subtitleStreams)):
# # subtitleStreams[streamIndex]['disposition']['forced'] = 1 if streamIndex == forcedSubtitle else 0
# #
# # # Reorder subtitle stream descriptors and create disposition options if default is given per command line option
# # if defaultSubtitle == -1:
# # sourceSubtitleStreams = subtitleStreams
# # else:
# # for streamIndex in range(len(subtitleStreams)):
# # subtitleStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == defaultSubtitle else 0
# #
# # sourceSubtitleStreams = getReorderedSubstreams(subtitleStreams, defaultSubtitle) if jellyfin else subtitleStreams
# #
# # dispositionTokens += generateDispositionTokens(sourceSubtitleStreams)
# #
#
#
# # # Create mapping and ffmpeg options for subtitle streams
#
# # if context['import_subtitles']:
# #
# # numMatchingSubtitles = len(matchingSubtitles)
# #
# # if jellyfin and defaultSubtitle != -1:
# # subtitleSequence = getModifiedStreamOrder(numMatchingSubtitles, default_subtitle) #!
# # else:
# # subtitleSequence = range(numMatchingSubtitles)
# #
# # for fileIndex in range(numMatchingSubtitles):
# #
# # # Create mapping for subtitle streams when imported from files
# # mappingTokens += ['-map', f"{subtitleSequence[fileIndex]+1}:s:0"]
# #
# # msg = matchingSubtitles[fileIndex]
# # subtitleMetadataTokens += [f"-metadata:s:s:{fileIndex}", f"language={msg['language']}"]
# # if 'title' in matchingSubtitles[fileIndex].keys():
# # subtitleMetadataTokens += [f"-metadata:s:s:{fileIndex}", f"title={matchingSubtitles[fileIndex]['title']}"]
# #
# # 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
# targetFilenameJobTokens = targetFilenameTokens.copy()
#
# if len(q_list) > 1:
# targetFilenameJobTokens += [f"q{q}"]
#
# # In case source and target filenames are the same add an extension to distinct output from input
# if not label and sourceFilenameExtension == targetFilenameExtension:
# targetFilenameJobTokens += ['ffx']
#
# targetFilename = '_'.join(targetFilenameJobTokens) # + '.' + targetFilenameExtension
#
# click.echo(f"target filename: {targetFilename}")
#
#
# if video_encoder == 'av1':
#
# commandSequence = (commandTokens
# + subtitleImportFileTokens
# + mappingTokens
# + audioMetadataTokens
# + subtitleMetadataTokens
# + audioDispositionTokens
# + subtitleDispositionTokens
# + audioEncodingTokens
# + generateAV1Tokens(q, preset) + audioEncodingTokens)
#
# if clear_metadata:
# commandSequence += generateClearTokens(sourceStreamDescriptor)
#
# commandSequence += cropTokens
#
# commandSequence += generateOutputTokens(targetFilename, DEFAULT_FILE_FORMAT, DEFAULT_FILE_EXTENSION)
#
# click.echo(f"Command: {' '.join(commandSequence)}")
#
# if not dry_run:
# executeProcess(commandSequence)
#
#
# if video_encoder == 'vp9':
#
# commandSequence1 = commandTokens + mappingVideoTokens + generateVP9Pass1Tokens(q)
#
# commandSequence1 += cropTokens
#
# commandSequence1 += NULL_TOKENS
#
# click.echo(f"Command 1: {' '.join(commandSequence1)}")
#
# if os.path.exists(TEMP_FILE_NAME):
# os.remove(TEMP_FILE_NAME)
#
# if not dry_run:
# executeProcess(commandSequence1)
#
#
# commandSequence2 = (commandTokens
# + subtitleImportFileTokens
# + mappingTokens
# + audioMetadataTokens
# + subtitleMetadataTokens
# + audioDispositionTokens
# + subtitleDispositionTokens
# + dispositionTokens)
#
# if denoise:
# commandSequence2 += generateDenoiseTokens()
#
# commandSequence2 += generateVP9Pass2Tokens(q) + audioEncodingTokens
#
# if clear_metadata:
# commandSequence2 += generateClearTokens(sourceStreamDescriptor)
#
# commandSequence2 += cropTokens
#
# commandSequence2 += generateOutputTokens(targetFilename, DEFAULT_FILE_FORMAT, DEFAULT_FILE_EXTENSION)
#
# click.echo(f"Command 2: {' '.join(commandSequence2)}")
#
# if not dry_run:
# executeProcess(commandSequence2)
#
#
# #app = ModesApp(context)
# #app.run()
#
# #click.confirm('Warning! This file is not compliant to the defined source schema! Do you want to continue?', abort=True)
#
# click.echo('\nDONE\n')
endTime = time.perf_counter()
click.echo(f"Time elapsed {endTime - startTime}")
if __name__ == '__main__':
ffx()