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

930 lines
39 KiB
Python

#! /usr/bin/python3
import os, sys, subprocess, json, click, time, re
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Placeholder, Label
VERSION='0.1.0'
DEFAULT_VIDEO_ENCODER = 'vp9'
DEFAULT_QUALITY = 23
DEFAULT_AV1_PRESET = 5
DEFAULT_FILE_FORMAT = 'webm'
DEFAULT_FILE_EXTENSION = 'webm'
DEFAULT_STEREO_BANDWIDTH = "128"
DEFAULT_AC3_BANDWIDTH = "256"
DEFAULT_DTS_BANDWIDTH = "320"
DEFAULT_CROP_START = 60
DEFAULT_CROP_LENGTH = 180
TEMP_FILE_NAME = "ffmpeg2pass-0.log"
MKVMERGE_METADATA_KEYS = ['BPS',
'NUMBER_OF_FRAMES',
'NUMBER_OF_BYTES',
'_STATISTICS_WRITING_APP',
'_STATISTICS_WRITING_DATE_UTC',
'_STATISTICS_TAGS']
FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
COMMAND_TOKENS = ['ffmpeg', '-y']
NULL_TOKENS = ['-f', 'null', '/dev/null']
STREAM_TYPE_VIDEO = 'video'
STREAM_TYPE_AUDIO = 'audio'
STREAM_TYPE_SUBTITLE = 'subtitle'
STREAM_LAYOUT_6_1 = '6.1'
STREAM_LAYOUT_5_1 = '5.1(side)'
STREAM_LAYOUT_STEREO = 'stereo'
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):
def __init__(self):
super().__init__()
context = self.app.getContext()
context['dashboard'] = 'dashboard'
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield Placeholder("Dashboard Screen")
yield Footer()
class WarningScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
def compose(self) -> ComposeResult:
yield Label("Warning! This file is not compliant to the defined source schema!")
yield Footer()
class SettingsScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
def compose(self) -> ComposeResult:
yield Placeholder("Settings Screen")
yield Footer()
class HelpScreen(Screen):
def __init__(self):
super().__init__()
context = self.app.getContext()
def compose(self) -> ComposeResult:
yield Placeholder("Help Screen")
yield Footer()
class ModesApp(App):
BINDINGS = [
("q", "quit()", "Quit"),
# ("d", "switch_mode('dashboard')", "Dashboard"),
# ("s", "switch_mode('settings')", "Settings"),
# ("h", "switch_mode('help')", "Help"),
]
MODES = {
"warning": WarningScreen,
"dashboard": DashboardScreen,
"settings": SettingsScreen,
"help": HelpScreen,
}
def __init__(self, context = {}):
super().__init__()
self.context = context
def on_mount(self) -> None:
self.switch_mode("warning")
def getContext(self):
return self.context
def executeProcess(commandSequence):
process = subprocess.Popen(commandSequence, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = process.communicate()
return output.decode('utf-8'), error.decode('utf-8'), process.returncode
#[{'index': 0, 'codec_name': 'vp9', 'codec_long_name': 'Google VP9', 'profile': 'Profile 0', 'codec_type': 'video', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'width': 1920, 'height': 1080, 'coded_width': 1920, 'coded_height': 1080, 'closed_captions': 0, 'film_grain': 0, 'has_b_frames': 0, 'sample_aspect_ratio': '1:1', 'display_aspect_ratio': '16:9', 'pix_fmt': 'yuv420p', 'level': -99, 'color_range': 'tv', 'chroma_location': 'left', 'field_order': 'progressive', 'refs': 1, 'r_frame_rate': '24000/1001', 'avg_frame_rate': '24000/1001', 'time_base': '1/1000', 'start_pts': 0, 'start_time': '0.000000', 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'BPS': '7974017', 'NUMBER_OF_FRAMES': '34382', 'NUMBER_OF_BYTES': '1429358655', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 libvpx-vp9', 'DURATION': '00:23:54.016000000'}}]
#[{'index': 1, 'codec_name': 'opus', 'codec_long_name': 'Opus (Opus Interactive Audio Codec)', 'codec_type': 'audio', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'sample_fmt': 'fltp', 'sample_rate': '48000', 'channels': 2, 'channel_layout': 'stereo', 'bits_per_sample': 0, 'initial_padding': 312, 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'extradata_size': 19, 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'jpn', 'title': 'Japanisch', 'BPS': '128000', 'NUMBER_OF_FRAMES': '61763', 'NUMBER_OF_BYTES': '22946145', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 libopus', 'DURATION': '00:23:54.141000000'}}]
#[{'index': 2, 'codec_name': 'webvtt', 'codec_long_name': 'WebVTT subtitle', 'codec_type': 'subtitle', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'duration_ts': 1434141, 'duration': '1434.141000', 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'ger', 'title': 'Deutsch [Full]', 'BPS': '118', 'NUMBER_OF_FRAMES': '300', 'NUMBER_OF_BYTES': '21128', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 webvtt', 'DURATION': '00:23:54.010000000'}}, {'index': 3, 'codec_name': 'webvtt', 'codec_long_name': 'WebVTT subtitle', 'codec_type': 'subtitle', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'duration_ts': 1434141, 'duration': '1434.141000', 'disposition': {'default': 0, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'eng', 'title': 'Englisch [Full]', 'BPS': '101', 'NUMBER_OF_FRAMES': '276', 'NUMBER_OF_BYTES': '16980', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 webvtt', 'DURATION': '00:23:53.230000000'}}]
def getStreamData(filepath):
"""Returns ffprobe stream data as array with elements according to the following example
{
"index": 4,
"codec_name": "hdmv_pgs_subtitle",
"codec_long_name": "HDMV Presentation Graphic Stream subtitles",
"codec_type": "subtitle",
"codec_tag_string": "[0][0][0][0]",
"codec_tag": "0x0000",
"r_frame_rate": "0/0",
"avg_frame_rate": "0/0",
"time_base": "1/1000",
"start_pts": 0,
"start_time": "0.000000",
"duration_ts": 1421035,
"duration": "1421.035000",
"disposition": {
"default": 1,
"dub": 0,
"original": 0,
"comment": 0,
"lyrics": 0,
"karaoke": 0,
"forced": 0,
"hearing_impaired": 0,
"visual_impaired": 0,
"clean_effects": 0,
"attached_pic": 0,
"timed_thumbnails": 0,
"non_diegetic": 0,
"captions": 0,
"descriptions": 0,
"metadata": 0,
"dependent": 0,
"still_image": 0
},
"tags": {
"language": "ger",
"title": "German Full"
}
}
"""
ffprobeOutput, ffprobeError, returnCode = executeProcess(["ffprobe",
"-show_streams",
"-of", "json",
filepath])
if 'Invalid data found when processing input' in ffprobeError:
raise Exception(f"File {filepath} does not contain valid stream data")
if returnCode != 0:
raise Exception(f"ffprobe returned with error {returnCode}")
return json.loads(ffprobeOutput)['streams']
def getStreamDescriptor(filename):
streamData = getStreamData(filename)
descriptor = {}
descriptor['video'] = []
descriptor['audio'] = []
descriptor['subtitle'] = []
for subStream in streamData:
s = subStream.copy()
#Defaulting to undefined if tag not defined for stream
if 'tags' in subStream.keys() and 'language' in subStream['tags'].keys():
s['language'] = subStream['tags']['language']
else:
s['language'] = 'undefined'
#Defaulting to undefined if tag not defined for stream
if 'tags' in subStream.keys() and 'title' in subStream['tags'].keys():
s['title'] = subStream['tags']['title']
else:
s['title'] = 'undefined'
if subStream['codec_type'] == STREAM_TYPE_AUDIO:
if 'channel_layout' in subStream.keys():
s['layout'] = subStream['channel_layout']
elif subStream['channels'] == 6:
s['layout'] = STREAM_LAYOUT_6CH
else:
s['layout'] = 'undefined'
descriptor[s['codec_type']].append(s)
descriptor[s['codec_type']][-1]['src_sub_index'] = len(descriptor[s['codec_type']]) - 1
return descriptor
def getModifiedStreamOrder(length, last):
"""This is jellyfin specific as the last stream in the order is set as default"""
seq = list(range(length))
if last < 0 or last > length -1:
return seq
seq.pop(last)
seq.append(last)
return seq
def getReorderedSubstreams(subDescriptor, last):
numSubStreams = len(subDescriptor)
modifiedOrder = getModifiedStreamOrder(numSubStreams, last)
reorderedDescriptor = []
for streamIndex in range(numSubStreams):
reorderedDescriptor.append(subDescriptor[modifiedOrder[streamIndex]])
return reorderedDescriptor
def generateAV1Tokens(q, p):
return ['-c:v:0', 'libsvtav1',
'-svtav1-params', f"crf={q}:preset={p}:tune=0:enable-overlays=1:scd=1:scm=0",
'-pix_fmt', 'yuv420p10le']
def generateVP9Pass1Tokens(q):
return ['-c:v:0', 'libvpx-vp9',
'-row-mt', '1',
'-crf', str(q),
'-pass', '1',
'-speed', '4',
'-frame-parallel', '0',
'-g', '9999',
'-aq-mode', '0']
def generateVP9Pass2Tokens(q):
return ['-c:v:0', 'libvpx-vp9',
'-row-mt', '1',
'-crf', str(q),
'-pass', '2',
'-frame-parallel', '0',
'-g', '9999',
'-aq-mode', '0',
'-auto-alt-ref', '1',
'-lag-in-frames', '25']
def generateCropTokens(start, length):
return ['-ss', str(start), '-t', str(length)]
def generateDenoiseTokens(spatial=5, patch=7, research=7, hw=False):
filterName = 'nlmeans_opencl' if hw else 'nlmeans'
return ['-vf', f"{filterName}=s={spatial}:p={patch}:r={research}"]
def generateOutputTokens(filepath, format, ext):
return ['-f', format, f"{filepath}.{ext}"]
def generateAudioTokens(context, index, layout):
if layout == STREAM_LAYOUT_6_1:
return [f"-c:a:{index}",
'libopus',
f"-filter:a:{index}",
'channelmap=channel_layout=6.1',
f"-b:a:{index}",
context['bitrates']['dts']]
elif layout == STREAM_LAYOUT_5_1:
return [f"-c:a:{index}",
'libopus',
f"-filter:a:{index}",
"channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1",
f"-b:a:{index}",
context['bitrates']['ac3']]
elif layout == STREAM_LAYOUT_STEREO:
return [f"-c:a:{index}",
'libopus',
f"-b:a:{index}",
context['bitrates']['stereo']]
elif layout == STREAM_LAYOUT_6CH:
return [f"-c:a:{index}",
'libopus',
f"-filter:a:{index}",
"channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1",
f"-b:a:{index}",
context['bitrates']['ac3']]
else:
return []
def generateClearTokens(streams):
clearTokens = []
for s in streams:
for k in MKVMERGE_METADATA_KEYS:
clearTokens += [f"-metadata:s:{s['type'][0]}:{s['sub_index']}", f"{k}="]
return clearTokens
def generateDispositionTokens(subDescriptor):
"""-disposition:s:X default+forced"""
dispositionTokens = []
for subStreamIndex in range(len(subDescriptor)):
subStream = subDescriptor[subStreamIndex]
streamType = subStream['codec_type'][0] # v|a|s
dispositionFlags = {k for (k,v) in subStream['disposition'].items() if v == 1} if 'disposition' in subStream.keys() else set()
if dispositionFlags:
dispositionTokens += [f"-disposition:{streamType}:{subStreamIndex}", '+'.join(dispositionFlags)]
else:
dispositionTokens += [f"-disposition:{streamType}:{subStreamIndex}", '0']
return dispositionTokens
def searchSubtitleFiles(dir, prefix):
sesl_match = re.compile(SEASON_EPISODE_STREAM_LANGUAGE_MATCH)
availableFileSubtitles = []
for subtitleFilename in os.listdir(dir):
if subtitleFilename.startswith(prefix) and subtitleFilename.endswith('.' + SUBTITLE_FILE_EXTENSION):
sesl_result = sesl_match.search(subtitleFilename)
if sesl_result is not None:
subtitleFilePath = os.path.join(dir, 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)
availableFileSubtitles.append(subtitleFileDescriptor)
click.echo(f"Found {len(availableFileSubtitles)} subtitles in files\n")
return availableFileSubtitles
@click.group()
@click.pass_context
def ffx(ctx):
"""FFX"""
ctx.obj = {}
pass
# 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]")
@click.argument('filename', nargs=1)
@ffx.command()
def streams(filename):
try:
sd = getStreamDescriptor(filename)
except Exception as ex:
raise click.ClickException(f"This file does not contain any audiovisual data: {ex}")
for d in sd:
click.echo(f"{d['codec_name']}{' (' + str(d['channels']) + ')' if d['codec_type'] == 'audio' else ''}")
@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=DEFAULT_VIDEO_ENCODER, help=f"Target video encoder (vp9 or av1) default: {DEFAULT_VIDEO_ENCODER}")
@click.option('-q', '--quality', type=str, default=DEFAULT_QUALITY, help=f"Quality settings to be used with VP9 encoder (default: {DEFAULT_QUALITY})")
@click.option('-p', '--preset', type=str, default=DEFAULT_AV1_PRESET, help=f"Quality preset to be used with AV1 encoder (default: {DEFAULT_AV1_PRESET})")
@click.option('-a', '--stereo-bitrate', type=int, default=DEFAULT_STEREO_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode stereo audio streams (default: {DEFAULT_STEREO_BANDWIDTH})")
@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('-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("-j", "--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,
subtitle_language,
subtitle_title,
default_subtitle,
forced_subtitle,
audio_language,
audio_title,
default_audio,
forced_audio,
crop,
output_directory,
clear_metadata,
denoise,
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
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']}")
se_match = re.compile(SEASON_EPISODE_INDICATOR_MATCH)
e_match = re.compile(EPISODE_INDICATOR_MATCH)
# Parse subtitle files
context['import_subtitles'] = (subtitle_directory and subtitle_prefix)
availableFileSubtitles = searchSubtitleFiles(subtitle_directory, subtitle_prefix) if context['import_subtitles'] else []
# Overwrite audio tags if set
audioLanguages = audio_language
audioTitles = audio_title
# Overwrite subtitle tags if set
subtitleLanguages = subtitle_language
subtitleTitles = subtitle_title
# Process crop parameters
context['perform_crop'] = (crop != 'none')
if context['perform_crop']:
cropTokens = crop.split(',')
if cropTokens and len(cropTokens) == 2:
context['crop_start'], context['crop_length'] = crop.split(',')
else:
context['crop_start'] = DEFAULT_CROP_START
context['crop_length'] = DEFAULT_CROP_LENGTH
click.echo(f"crop start={context['crop_start']} length={context['crop_length']}")
job_index = 0
existingSourcePaths = [p for p in paths if os.path.isfile(p)]
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('.')
if sourcePathTokens[-1] in FILE_EXTENSIONS:
sourceFileBasename = '.'.join(sourcePathTokens[:-1])
sourceFilenameExtension = sourcePathTokens[-1]
else:
sourceFileBasename = sourceFilename
sourceFilenameExtension = ''
click.echo(f"\nProcessing file {sourcePath}")
# Determine season and episode if present in current filename
season_digits = 2
episode_digits = 2
index_digits = 3
se_result = se_match.search(sourceFilename)
e_result = e_match.search(sourceFilename)
season = -1
episode = -1
file_index = 0
if se_result is not None:
season = int(se_result.group(1))
episode = int(se_result.group(2))
elif e_result is not None:
episode = int(e_result.group(1))
else:
file_index += 1
print(f"season={season} episode={episode} file={file_index}")
# Assemble target filename tokens
targetFilenameTokens = []
targetFilenameExtension = 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]
try:
sourceStreamDescriptor = getStreamDescriptor(sourcePath)
targetStreamDescriptor = sourceStreamDescriptor.copy()
except Exception:
click.echo(f"File with path {sourcePath} does not contain any audiovisual data, skipping ...")
continue
click.echo('Source streans:')
for aStream in sourceStreamDescriptor[STREAM_TYPE_AUDIO]:
click.echo(f"audio stream {aStream['src_sub_index']} lang={aStream['language']} title={aStream['title']} default={aStream['disposition']['default']} forced={aStream['disposition']['forced']}")
for sStream in sourceStreamDescriptor[STREAM_TYPE_SUBTITLE]:
click.echo(f"subtitle stream {sStream['src_sub_index']} lang={sStream['language']} title={sStream['title']} default={sStream['disposition']['default']} forced={sStream['disposition']['forced']}")
#NOTE: It is currently expected that all source file have the same substream pattern, e.g. coming from the same encoder
defaultAudioStreams = [a for a in sourceStreamDescriptor[STREAM_TYPE_AUDIO] if 'disposition' in a.keys() and 'default' in a['disposition'].keys() and a['disposition']['default'] == 1]
if default_audio == -1 and len(defaultAudioStreams) > 1:
default_audio = click.prompt("More than one default audio stream detected! Please select stream", type=int)
forcedAudioStreams = [a for a in sourceStreamDescriptor[STREAM_TYPE_AUDIO] if 'disposition' in a.keys() and 'default' in a['disposition'].keys() and a['disposition']['forced'] == 1]
if forced_audio == -1 and len(forcedAudioStreams) > 1:
forced_audio = click.prompt("More than one forced audio stream detected! Please select stream", type=int)
defaultSubtitleStreams = [s for s in sourceStreamDescriptor[STREAM_TYPE_SUBTITLE] if 'disposition' in s.keys() and 'default' in s['disposition'].keys() and s['disposition']['default'] == 1]
if default_subtitle == -1 and len(defaultSubtitleStreams) > 1:
default_subtitle = click.prompt("More than one default subtitle stream detected! Please select stream", type=int)
forcedSubtitleStreams = [s for s in sourceStreamDescriptor[STREAM_TYPE_SUBTITLE] if 'disposition' in s.keys() and 'default' in s['disposition'].keys() and s['disposition']['forced'] == 1]
if forced_subtitle == -1 and len(forcedSubtitleStreams) > 1:
forced_subtitle = click.prompt("More than one forced subtitle stream detected! Please select stream", type=int)
#Fix multiple default/forced tags
if default_audio != -1:
for substreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_AUDIO])):
if not 'disposition' in targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex].keys():
targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex]['disposition'] = {}
targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex]['disposition']['default'] = 1 if substreamIndex == default_audio else 0
if forced_audio != -1:
for substreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_AUDIO])):
if not 'disposition' in targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex].keys():
targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex]['disposition'] = {}
targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex]['disposition']['forced'] = 1 if substreamIndex == forced_audio else 0
if default_subtitle != -1:
for substreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_SUBTITLE])):
if not 'disposition' in targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex].keys():
targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition'] = {}
targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition']['default'] = 1 if substreamIndex == default_subtitle else 0
if forced_subtitle != -1:
for substreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_SUBTITLE])):
if not 'disposition' in targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex].keys():
targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition'] = {}
targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition']['forded'] = 1 if substreamIndex == forced_subtitle else 0
click.echo('Target streans:')
for aStream in targetStreamDescriptor[STREAM_TYPE_AUDIO]:
click.echo(f"audio stream {aStream['src_sub_index']} lang={aStream['language']} title={aStream['title']} default={aStream['disposition']['default']} forced={aStream['disposition']['forced']}")
for sStream in targetStreamDescriptor[STREAM_TYPE_SUBTITLE]:
click.echo(f"subtitle stream {sStream['src_sub_index']} lang={sStream['language']} title={sStream['title']} default={sStream['disposition']['default']} forced={sStream['disposition']['forced']}")
commandTokens = COMMAND_TOKENS + ['-i', sourcePath]
subtitleFileTokens = []
matchingSubtitles = []
if context['import_subtitles']:
subtitles = [a for a in availableFileSubtitles 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 streamIndex <= len(subtitleTitles) -1:
mSubtitles[streamIndex]['title'] = subtitleTitles[streamIndex]
if default_subtitle != -1 and jellyfin:
matchingSubtitles = getReorderedSubstreams(mSubtitles, default_subtitle)
else:
matchingSubtitles = mSubtitles
for q in q_list:
click.echo(f"\nRunning job {job_index} file={sourcePath} q={q}")
job_index += 1
mappingVideoTokens = ['-map', '0:v:0']
mappingTokens = mappingVideoTokens.copy()
dispositionTokens = []
audioTokens = []
# Source stream descriptors
audioStreams = sourceStreamDescriptor[STREAM_TYPE_AUDIO]
subtitleStreams = sourceStreamDescriptor[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) if jellyfin else audioStreams
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) if jellyfin else subtitleStreams
dispositionTokens += generateDispositionTokens(sourceSubtitleStreams)
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'])
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']:
numMatchingSubtitles = len(matchingSubtitles)
if jellyfin and default_subtitle != -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
+ subtitleFileTokens
+ mappingTokens
+ dispositionTokens
+ audioMetadataTokens
+ subtitleMetadataTokens
+ audioTokens
+ generateAV1Tokens(q, preset) + audioTokens)
if clear_metadata:
commandSequence += generateClearTokens(sourceStreamDescriptor)
if context['perform_crop']:
commandSequence += generateCropTokens(context['crop_start'], context['crop_length'])
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)
if context['perform_crop']:
commandSequence1 += generateCropTokens(context['crop_start'], context['crop_length'])
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
+ subtitleFileTokens
+ mappingTokens
+ audioMetadataTokens
+ subtitleMetadataTokens
+ dispositionTokens)
if denoise:
commandSequence2 += generateDenoiseTokens()
commandSequence2 += generateVP9Pass2Tokens(q) + audioTokens
if clear_metadata:
commandSequence2 += generateClearTokens(sourceStreamDescriptor)
if context['perform_crop']:
commandSequence2 += generateCropTokens(context['crop_start'], context['crop_length'])
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}")
# click.echo(f"app result: {app.getContext()}")
if __name__ == '__main__':
ffx()