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.
401 lines
16 KiB
Python
401 lines
16 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.show_controller import ShowController
|
|
from ffx.tmdb_controller import TmdbController
|
|
|
|
from ffx.database import databaseContext
|
|
|
|
from ffx.track_type import TrackType
|
|
from ffx.video_encoder import VideoEncoder
|
|
|
|
|
|
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('-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('-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('-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("--crop", is_flag=False, flag_value="default", default="none")
|
|
|
|
@click.option("-o", "--output-directory", type=str, default='')
|
|
|
|
@click.option("-d", "--denoise", is_flag=True, default=False)
|
|
|
|
@click.option("-t", "--no-tmdb", is_flag=True, default=False)
|
|
@click.option("-j", "--no-jellyfin", 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,
|
|
subtitle_directory,
|
|
subtitle_prefix,
|
|
|
|
audio_language,
|
|
audio_title,
|
|
default_audio,
|
|
forced_audio,
|
|
|
|
subtitle_language,
|
|
subtitle_title,
|
|
default_subtitle,
|
|
forced_subtitle,
|
|
|
|
crop,
|
|
output_directory,
|
|
denoise,
|
|
no_tmdb,
|
|
no_jellyfin,
|
|
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['dry_run'] = True # dry_run
|
|
|
|
context['video_encoder'] = VideoEncoder.fromLabel(video_encoder)
|
|
|
|
context['jellyfin'] = not no_jellyfin
|
|
context['tmdb'] = not no_tmdb
|
|
|
|
context['import_subtitles'] = (subtitle_directory and subtitle_prefix)
|
|
if context['import_subtitles']:
|
|
context['subtitle_directory'] = subtitle_directory
|
|
context['subtitle_prefix'] = subtitle_prefix
|
|
|
|
# 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:
|
|
context['crop_start'] = int(cTokens[0])
|
|
context['crop_lenght'] = int(cTokens[1])
|
|
click.echo(f"Crop start={context['crop_start']} length={context['crop_length']}")
|
|
|
|
|
|
tc = TmdbController()
|
|
|
|
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")
|
|
jobIndex = 0
|
|
|
|
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'}")
|
|
|
|
fileBasename = ''
|
|
|
|
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)
|
|
|
|
if context['import_subtitles']:
|
|
sourceMediaDescriptor.importSubtitles(context['subtitle_directory'], context['subtitle_prefix'])
|
|
|
|
fc = FfxController(context, 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}")
|
|
|
|
else:
|
|
|
|
# Case pattern matching
|
|
|
|
targetMediaDescriptor = currentPattern.getMediaDescriptor()
|
|
currentShowDescriptor = currentPattern.getShowDescriptor()
|
|
|
|
|
|
if context['tmdb']:
|
|
|
|
tmdbEpisodeResult = tc.queryEpisode(currentShowDescriptor.getId(), mediaFileProperties.getSeason(), mediaFileProperties.getEpisode())
|
|
|
|
# click.echo(f"{tmdbEpisodeResult}")
|
|
|
|
if tmdbEpisodeResult:
|
|
fileBasename = tc.getEpisodeFileBasename(currentShowDescriptor.getFilenamePrefix(),
|
|
tmdbEpisodeResult['name'],
|
|
mediaFileProperties.getSeason(),
|
|
mediaFileProperties.getEpisode(),
|
|
currentShowDescriptor.getIndexSeasonDigits(),
|
|
currentShowDescriptor.getIndexEpisodeDigits(),
|
|
currentShowDescriptor.getIndicatorSeasonDigits(),
|
|
currentShowDescriptor.getIndicatorEpisodeDigits())
|
|
|
|
|
|
else:
|
|
fileBasename = currentShowDescriptor.getFilenamePrefix()
|
|
|
|
click.echo(f"fileBasename={fileBasename}")
|
|
|
|
if context['import_subtitles']:
|
|
targetMediaDescriptor.importSubtitles(context['subtitle_directory'], context['subtitle_prefix'])
|
|
|
|
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()}")
|
|
|
|
|
|
for q in q_list:
|
|
|
|
click.echo(f"\nRunning job {jobIndex} file={sourcePath} q={q}")
|
|
jobIndex += 1
|
|
|
|
extra = ['ffx'] if sourceFilenameExtension == FfxController.DEFAULT_FILE_EXTENSION else []
|
|
|
|
targetFilename = fileBasename if context['tmdb'] else mediaFileProperties.assembleTargetFileBasename(label if label else fileBasename,
|
|
q if len(q_list) > 1 else -1,
|
|
extraTokens = extra)
|
|
|
|
targetPath = os.path.join(output_directory if output_directory else sourceDirectory, targetFilename)
|
|
|
|
fc.runJob(sourcePath,
|
|
targetPath,
|
|
context['video_encoder'],
|
|
q,
|
|
preset,
|
|
denoise)
|
|
|
|
# #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"\nDONE\nTime elapsed {endTime - startTime}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
ffx()
|